Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8425e6c673
|
||
|
|
39c86cdcae | ||
|
|
ec6e382b69
|
||
|
|
f4d3f2c105
|
||
|
|
5d687b37a7
|
||
|
|
a369e3c7b0
|
||
|
|
9e465d7b7c
|
||
|
|
fe4970e10c
|
||
|
|
f90a1192b4
|
||
|
|
4c79c330b4
|
||
|
|
9b01e6087c
|
||
|
|
5eabd24f9b
|
||
|
|
fd061f6e8c
|
||
|
|
4849764e2d | ||
|
|
4761a0242b | ||
|
|
1bb2371d75 | ||
|
|
a01ad9487f | ||
|
|
95ae9ff23d | ||
|
|
eab599edac | ||
|
|
bd0f76cad5 | ||
|
|
f8d67b28c9 | ||
|
|
e4bb261ead
|
||
|
|
6906859648 | ||
|
|
1c3d163104
|
||
|
|
3803e3e398 | ||
|
|
cb15fc0c27
|
||
|
|
171112162a | ||
|
|
b733cba229
|
||
|
|
c232014d28 | ||
|
|
4bd28c5764
|
||
|
|
89f1c29d49 | ||
|
|
adbefe7c33
|
||
|
|
7aa971bb22 | ||
|
|
36a1b35ea1 | ||
|
|
5b2589b1d5
|
||
|
|
f9f58d0a8f | ||
|
|
acc8e1a40a
|
||
|
|
2adf563a4a
|
||
|
|
1ee64c95fe
|
||
|
|
b4387881c8 | ||
|
|
7e6667aaff | ||
|
|
70d3bc65e6
|
||
|
|
89a1a76952
|
||
|
|
1c0fa34e7c
|
||
|
|
0e37be1070
|
||
|
|
30182b4805
|
||
|
|
5dd68eb20b
|
||
|
|
d732cef626
|
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
+11
-1
@@ -4,7 +4,15 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Added
|
||||
- Check Readiness warns when a channel's `rulesVerifiedDate` is older than the new `staleChannelRulesDays` (default 90), on a campaign whose content uses the channel, like `staleSourceDays` does for evidence sources. The warning, and the "rules not verified" one, now quote the channel's `rulesSourceUrl`. The agent's campaign view also shows the lead qualification note.
|
||||
- The help bundle is registered with the QQQ core help registry (`HelpBundles.register`, qqq#1198), replacing the `MarketingHelp` stand-in, so it shows when a table is empty on hosts with core help content. `HelpBundleCoverageTest` uses core `HelpBundleAssertions`. Every table says "How rows get created" and names **Load Verified Starter Data** where it seeds the table. The bundle version is `1`.
|
||||
- `MarketingTablesGuardrailTest` and `ChannelRulesStalenessTest`.
|
||||
|
||||
### Changed
|
||||
- The Marketing home is business content only: **Configuration Status** and **Agent Activity** (provider configuration, model/tool calls and tokens) are off the home and exposed as `MarketingWidgets.ADMIN_WIDGETS` for a host to place on its Admin area; they need `marketing.admin.read` (hidden without it). **Needs Attention** gains one row per active channel that cannot send ("Sends on <channel> are paused - ask an admin"), linking to the channel.
|
||||
- Home and record widgets require `marketing.read` through `MarketingPermissionChecker` (HIDDEN): a user with app access but no read no longer sees a "You need marketing.read ..." shell; the widgets are omitted.
|
||||
- `QbitCrmGateway` uses qbit-crm 0.2.0's `crm_*` snake_case table names (`crm_contact`, ...). qbit-crm 0.1.0's camelCase tables are no longer supported.
|
||||
- Make the agent limits distributed (#4): `maxConcurrentAgentRuns` counts Running `marketing_agent_run` rows in the database (a new `heartbeatAt` column, updated on each model call, lets runs whose server stopped be marked Failed first), and `maxModelCallsPerMinute` counts model-call steps in the last 60 seconds and makes a run wait rather than fail. The in-memory semaphore and rate window are gone; the settings and Marketing Policy override keep their names and defaults (the policy `maxConcurrentAgentRuns` override now applies). Hosts add `heartbeat_at` to `marketing_agent_run`. The count-then-insert window is still racy; a conditional-update claim (qqq#1178) is a follow-up.
|
||||
- Move to the assistant provider SPI `0.1.0-RC.12` (package `com.kingsrook.qbits.assistant.spi`) and `qbit-build-parent` `2.1.2`, and drop the per-repository guardrail allow-lists. `qbit-assistant-provider-openai` becomes a test dependency; hosts that register it add it themselves.
|
||||
- Switch the optional qbit-email integration to the published `com.kingsrook.qbits.email.api` package in `0.1.0-RC.3` (fixes #13). Preserve MARKETING messages, idempotency keys, execution links and outcome mapping; use typed readiness failures so missing providers or brand postal addresses remain send-time rejections. Upgrade `qbit-build-parent` to `2.1.1` and remove all per-repository guardrail allow-lists.
|
||||
@@ -13,10 +21,12 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- Upgrade QQQ to `4.1.0-RC.6`; enable architecture enforcement and strict UI API routes.
|
||||
|
||||
### Added
|
||||
- **Persisted measurement** (issue #4, item 4): the `marketing_metric_daily` sub-table (**Daily Results** on a campaign; system-written, read-only, product-locked) rolls outcome events up per UTC day, stage, event type and test-data flag. `MeasurementRollupService` recomputes the affected campaign-days idempotently after every event insert, update and delete; **Rebuild Results** (campaign record action) rebuilds every day. `MeasurementReport` sums raw counts from the rollup and counts campaign-wide unique actors with a backend `COUNT_DISTINCT`; its output is unchanged. An event insert now defaults `occurredAt` to now and `isTestData` to false, and a blank `actorKey` is stored as null.
|
||||
- **Outcome importer SPI:** `MarketingOutcomeImporter`, registered with `MarketingQBitConfig.withOutcomeImporter(...)`, and the **Import Outcomes** campaign action, which stores outcomes through the normal (source, external id) dedupe. The Configuration Status widget lists the importers. No concrete network importer.
|
||||
- Help bundle `help/qbit-marketing.yaml` (346 entries) covering the app, tables, fields, actions, widgets and possible-value sources, with `HelpBundleCoverageTest` as the coverage gate until `HelpBundleAssertions` ships in core (qqq#1198). The producer registers it once that API exists.
|
||||
- Product-contingent possible values for editable campaign, claim, finding, metric and outcome-event references.
|
||||
- Repository scaffold: build, CI and the repository standard files.
|
||||
- **Model:** 18 standard `marketing_*` tables, 21 enum possible-value sources, joins and child lists. The PostgreSQL reference DDL (`docs/ddl/postgres.sql`) and an H2 twin are generated from one spec (`scripts/generate-model.py`).
|
||||
- **Model:** 19 standard `marketing_*` tables, 21 enum possible-value sources, joins and child lists. The PostgreSQL reference DDL (`docs/ddl/postgres.sql`) and an H2 twin are generated from one spec (`scripts/generate-model.py`).
|
||||
- **Products and evidence:**
|
||||
- Briefs, audiences, evidence sources with verification dates, and claims (Released / Planned / Unverified / Disputed).
|
||||
- Deterministic **Check Readiness** for products and campaigns.
|
||||
|
||||
@@ -22,8 +22,8 @@ It lives inside the host QQQ application: its navigation, authentication, permis
|
||||
| **Approval** | A person approves a hash of the exact text, destination, recipient scope, schedule and channel. Any edit voids it. Preparation, approval and execution are separate permissions. |
|
||||
| **Execution** | Durable jobs with idempotency keys, schedules, pause, resume and cancel, and attempts with provider receipts. Failures that certainly did not send are retried with backoff. Ambiguous outcomes are reconciled and never retried blindly. Everything is re-checked at send time (approval, schedule, consent, suppression, channel rules, rate, kill switch). |
|
||||
| **Transports** | A complete controlled test transport, and email through the host's QQQ messaging provider. A channel that names a transport is not treated as a working connector unless that transport is registered and usable. |
|
||||
| **Measurement** | Delivery, awareness, engagement, activation, repeat use and qualified demand are reported separately. Each metric shows raw events and unique actors. "Not measured" is never shown as 0. Test data is kept apart. |
|
||||
| **CRM** | Uses the host CRM's own contacts, organizations, consent, suppression, lists, form submissions and activities through a narrow gateway. A qbit-crm adapter is included. Nothing is copied, and nobody becomes a lead without the campaign's explicit rule. |
|
||||
| **Measurement** | Delivery, awareness, engagement, activation, repeat use and qualified demand are reported separately. Each metric shows raw events and unique actors. "Not measured" is never shown as 0. Test data is kept apart. Results are read from a persisted daily rollup (**Daily Results**, rebuilt by **Rebuild Results**), and a host can register outcome importers (**Import Outcomes**). |
|
||||
| **CRM** | Uses the host CRM's own contacts, organizations, consent, suppression, lists, form submissions and activities through a narrow gateway. A qbit-crm adapter is included (qbit-crm 0.2.0's `crm_*` tables). Nothing is copied, and nobody becomes a lead without the campaign's explicit rule. |
|
||||
| **Agent** | Plans campaigns, drafts and revises content, flags unsupported claims, stale sources and readiness gaps, and analyzes results to recommend the next experiment. It uses narrow typed tools, runs as the requesting user, sees data only as untrusted input, and sees no CRM personal data. Every run is recorded with its steps, provider, model, tokens and cost. It can create drafts and findings; it cannot approve, schedule or send. |
|
||||
| **Models** | Interchangeable providers through `qbit-assistant-provider-api`'s `AssistantModelProvider`: OpenAI-compatible endpoints through `qbit-assistant-provider-openai` (added by the host), or a host-supplied provider such as Claude. Tests use a local scripted double. With no provider configured, the agent says so and everything else works. Installing marketing does not install the assistant runtime, tables or routes. |
|
||||
|
||||
@@ -102,7 +102,11 @@ Set any existing Marketing Policy provider override to `openai-compatible` as we
|
||||
|
||||
The help text ships with the code it describes, as a QQQ help bundle at `help/qbit-marketing.yaml` (a classpath resource, layer `QBIT`). It has 346 entries: the Marketing app, every `marketing_*` table and each of its non-trivial fields, every record and table action, every widget (the dashboard widgets and the child lists) and every possible-value source. Each entry has a one-line summary for the tooltip and a few sentences of plain help. Labels, maximum lengths and enum values are `{{...}}` references, so the help does not repeat what the metadata already says.
|
||||
|
||||
The bundle is loaded with `HelpBundles.register(qInstance, "help/qbit-marketing.yaml")` once qqq#1198 ships. The producer will register it then; until that core API exists, nothing registers it. `HelpBundleCoverageTest` checks that the bundle is complete, that every key and reference resolves, and that summaries stay short and plain.
|
||||
The producer registers the bundle with the QQQ core help registry (`HelpBundles.register`, qqq#1198). Registration is metadata-only; on hosts that install core help content (`HelpContentMetaDataProvider`), core's startup sync writes the entries to `helpContent` and core resolves the `{{...}}` references when it serves them, so the table help shows when a table is empty. Because the bundle is at the `QBIT` layer, a host bundle or host-edited help for the same key wins. Every table entry says **How rows get created** and names the **Load Verified Starter Data** action (Marketing Product) for the eight tables it seeds. `HelpBundleCoverageTest` runs core's `HelpBundleAssertions` gate (complete coverage, every key and reference resolves, short plain summaries) and also checks widget and possible-value-source coverage.
|
||||
|
||||
## Starter data and setup
|
||||
|
||||
A new install has no products, channels or policy row, and the agent and execution stay off until the policy row exists. Run **Load Verified Starter Data** (Marketing Product table actions, needs `marketing.prepare`) once: it adds the four starter channels, the `default` policy and three verified products with their sources, claims, audiences, draft campaigns and measurement plans. It is idempotent and never changes what exists. See [docs/STARTER-DATA.md](docs/STARTER-DATA.md). The Configuration Status widget on the home shows what is configured. The other tables (content, approvals, executions, attempts, test deliveries, agent runs and steps, findings, outcome events and daily results) hold work and results and start empty by design; their help says how rows get created. Channel rules go stale: Check Readiness warns when a channel's `rulesVerifiedDate` is older than `staleChannelRulesDays` (default 90).
|
||||
|
||||
## Email through qbit-email
|
||||
|
||||
@@ -122,9 +126,10 @@ When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) qbit is pr
|
||||
| All tables, actions, widgets, permissions, record locks | Claude and OpenAI-compatible providers (need credentials; tested only with the scripted provider and a local HTTP fixture) |
|
||||
| Approval binding, invalidation, separation of duties | Email transport (needs the host's QQQ messaging provider; reports *Not configured* until then, and *Configured* is not *Operational*) |
|
||||
| Durable execution, retries, reconciliation, leases | No social, community, blog or CMS connector exists: those channels are posted by hand |
|
||||
| Controlled test transport (complete) | No analytics importer: awareness and activation metrics stay *Not measured* until something records outcome events |
|
||||
| Controlled test transport (complete) | No concrete analytics importer exists (only the `MarketingOutcomeImporter` SPI): awareness and activation metrics stay *Not measured* until something records outcome events |
|
||||
| Agent loop, tools, budgets, limits, run records; read-tool registration in the QQQ RC.6 AI tool registry | |
|
||||
| CRM gateway and qbit-crm adapter, explicit lead rules | |
|
||||
| Persisted daily rollup of outcome events, Rebuild Results, and the importer SPI (tested with a fake importer; backend `SUM` / `COUNT_DISTINCT` tested on H2) | |
|
||||
| Persistence across restart (H2 file, and PostgreSQL in the Voyage host) | |
|
||||
|
||||
Scale gaps (single execution worker per JVM; agent limits are database-counted but still racy across replicas) are listed in [`docs/OPERATIONS.md`](docs/OPERATIONS.md#scale-and-limits).
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased
|
||||
|
||||
No schema change.
|
||||
|
||||
- The help bundle is now applied at run time, so tables show their help (including how rows get created) when empty. A host's own table help is kept.
|
||||
- New config `staleChannelRulesDays` (default 90). Check Readiness on a campaign now also warns when a channel its content uses has a `rulesVerifiedDate` older than that. Hosts that do not want it set a large number.
|
||||
- New hosts: run **Load Verified Starter Data** once to get the starter channels, the `default` policy and the starter products.
|
||||
+12
-4
@@ -27,7 +27,7 @@ The work is truthful and auditable: facts come from sources with dates, people a
|
||||
| **QQQ messaging** | `SendMessageAction` and the host's `QMessagingProviderMetaData` for email. |
|
||||
| **QQQ ESB / Quick Search** | Every write goes through core table actions, so record-change listeners (ESB publications) and Quick Search indexing apply. Hosts list `MAIN_TABLE_NAMES` in their ESB and Quick Search config (Voyage does this for Quick Search). |
|
||||
|
||||
## Data model (18 tables)
|
||||
## Data model (19 tables)
|
||||
|
||||
**Main tables** (in the nav):
|
||||
|
||||
@@ -61,6 +61,7 @@ The work is truthful and auditable: facts come from sources with dates, people a
|
||||
| `marketing_finding` | product, campaign, content, agent run | |
|
||||
| `marketing_metric` | campaign | The measurement plan. |
|
||||
| `marketing_outcome_event` | campaign | Raw events, deduplicated by (source, external id). |
|
||||
| `marketing_metric_daily` | campaign | **Daily Results**: the persisted rollup of the events per UTC day, stage, event type and test-data flag. System-written and read-only. |
|
||||
|
||||
Most rows carry a denormalized `productId`, so the optional product security lock covers every table. `scripts/generate-model.py` generates the entities, the 21 enum possible-value sources and the DDL from one spec.
|
||||
|
||||
@@ -75,7 +76,7 @@ Most rows carry a denormalized `productId`, so the optional product security loc
|
||||
- *With disclosure* requires the disclosure text in the body.
|
||||
- Optionally, the approver must not be the revision's author.
|
||||
- The approval row stores the hash, revision, destination, scope and schedule.
|
||||
- **Guarded tables:** approvals, executions, attempts, revisions, test deliveries, agent runs and steps are read-only in the UI and API. A guard customizer rejects any write outside Marketing's services, including the generic API and any tool.
|
||||
- **Guarded tables:** approvals, executions, attempts, revisions, test deliveries, agent runs and steps, and the daily results are read-only in the UI and API. A guard customizer rejects any write outside Marketing's services, including the generic API and any tool.
|
||||
|
||||
## Execution
|
||||
|
||||
@@ -113,6 +114,13 @@ Most rows carry a denormalized `productId`, so the optional product security loc
|
||||
- **Test data:** test executions and fixtures are flagged `isTestData` and excluded from the numbers, with a count.
|
||||
- **Fixed limitations:** provider acceptance is not delivery; visits, stars and downloads are not adoption.
|
||||
- **Deduplication:** outcome events are deduplicated by the unique key (source, external id) on every insert path: the admin UI, the API and services.
|
||||
- **Daily rollup:** the report does not load the campaign's events. `marketing_metric_daily` holds, per campaign, UTC day (`dayUtc`), stage, event type and test-data flag: the raw count (sum of quantities), the unique actors of that day and the events without an actor. The unique key is (campaign, day, stage, event type, test data).
|
||||
- **Kept in step:** `OutcomeEventRollupCustomizer` recomputes the affected (campaign, day)s after every insert, update and delete of an event, from any path (table, API, CRM import, importer, the execution worker). An update recomputes the old and the new day. `MeasurementRollupService` re-reads that day's raw events and upserts by the unique key, rewriting a row only when its numbers differ, and deletes rows that no longer have events. It is idempotent.
|
||||
- **Rebuild Results** (campaign record action, `marketing.prepare`, the same permission as Update Results from CRM) rebuilds every day of the selected campaigns, so a damaged rollup, or events written outside Marketing, can be repaired.
|
||||
- **Unique actors are not summed from days** (the same actor on two days is one actor). The report counts them over the whole campaign with a backend `COUNT_DISTINCT` of `actorKey` grouped by stage and event type; raw counts are backend `SUM`s over the rollup. Nothing is aggregated in memory.
|
||||
- **Events always have a time and a test flag:** an insert defaults `occurredAt` to now and `isTestData` to false; a blank `actorKey` is stored as null; an event cannot lose its time.
|
||||
- **Written only by Marketing:** the table is in `SYSTEM_WRITTEN_TABLE_NAMES` (guarded, no insert, edit or delete in the UI or API), carries `productId` for the product record lock and shows as the **Daily Results** child list on the campaign.
|
||||
- **Importers:** `MarketingOutcomeImporter` (`getName()`, `fetch(campaign, since)`) is the SPI for an analytics source, registered with `MarketingQBitConfig.withOutcomeImporter(...)`. **Import Outcomes** (campaign record action) runs every importer and stores each `ImportedOutcome` through the normal insert path with the importer's name as `source`, so repeats are dropped; `since` is the newest `occurredAt` already recorded for that importer and campaign. A failing importer is named in the result and does not stop the others. The qbit ships no concrete network importer. The Configuration Status widget lists the registered importers.
|
||||
|
||||
## CRM integration
|
||||
|
||||
@@ -122,7 +130,7 @@ Most rows carry a denormalized `productId`, so the optional product security loc
|
||||
- `listFormSubmissions(utmCampaign)`: ids and times only
|
||||
- `recordActivity`
|
||||
|
||||
`QbitCrmGateway` implements it over qbit-crm 0.1.0 by table name, through the standard actions, so there is no compile dependency on the CRM:
|
||||
`QbitCrmGateway` implements it over qbit-crm 0.2.0's `crm_*` tables (`crm_contact`, `crm_consent_record`, ...) by table name, through the standard actions, so there is no compile dependency on the CRM. It moves to qbit-crm's public `com.kingsrook.qbits.crm.api.CrmMarketingApi` once that is released:
|
||||
- **Email consent:** the most recent Email consent record decides (Granted and not expired). No record means no consent.
|
||||
- **Suppression:** `doNotEmail`, an unsubscribe date, or a list membership that isn't Subscribed.
|
||||
|
||||
@@ -199,7 +207,7 @@ The owner accepted every recommendation on 2026-10-12. The concrete defaults, re
|
||||
| OpenAI-compatible provider | Not configured | Optional; register it only for a specific need (for example a local Ollama for experiments). With two providers the host must name one. |
|
||||
| Email transport | QQQ messaging over Amazon SES, from a verified `qrun.io` domain (SPF, DKIM, DMARC), from-address `news@qrun.io`, replies to `hello@qrun.io` | SES is a low-cost, reliable sender and QQQ messaging supports it directly. Reports *Configured, not verified* until one end-to-end test send, then the host sets `withVerifiedOperational(true)`. |
|
||||
| Social and community posting | No transports. Posted by hand | No connector exists, and each community's rules must be checked before any automation. |
|
||||
| Measurement | Outcome events from the CRM import and by hand; no third-party analytics provider | Matches the honest-measurement rules: not instrumented shows *Not measured*. |
|
||||
| Measurement | Outcome events from the CRM import, by hand and from host-registered importers (SPI only); a persisted daily rollup; no third-party analytics provider | Matches the honest-measurement rules: not instrumented shows *Not measured*. |
|
||||
| CRM gateway | `QbitCrmGateway` (qbit-crm) | What Voyage's Marketing wiring uses. |
|
||||
|
||||
**AI-content channel policy**
|
||||
|
||||
+9
-2
@@ -23,6 +23,7 @@
|
||||
| `requireDifferentApprover` | `false` | Four-eyes: the author of a revision can't approve it. |
|
||||
| `aiDisclosureText` | `Prepared with AI assistance.` | Required in agent-written text for channels that allow AI content *with disclosure*. |
|
||||
| `staleSourceDays` | 90 | When evidence counts as stale. |
|
||||
| `staleChannelRulesDays` | 90 | When a channel's `rulesVerifiedDate` counts as stale: Check Readiness warns on a campaign whose content uses the channel, and points at the channel's `rulesSourceUrl`. |
|
||||
| `crmGateway` | none | `QbitCrmGateway` for qbit-crm, or your own `MarketingCrmGateway`. |
|
||||
|
||||
**Fail closed:** without the policy row, or unless a switch is explicitly on, the agent and execution are **off**. **Load Verified Starter Data** creates the row, or you can create it by hand. Configuration Status shows the policy state.
|
||||
@@ -83,10 +84,12 @@ Fix the cause. If the content changed, it needs a new approval. Then schedule ag
|
||||
**Pause or withdraw one item:** use **Pause** or **Cancel** on the execution, or **Return to Draft** on the content. Return to Draft revokes the content's approvals and cancels its pending executions.
|
||||
|
||||
**Recording outcomes:**
|
||||
- Insert outcome events through the table, the QQQ API or an importer. Set `source` and `externalEventId`: the pair is unique, so replays are dropped.
|
||||
- Insert outcome events through the table, the QQQ API or an importer. Set `source` and `externalEventId`: the pair is unique, so replays are dropped. An event without `occurredAt` gets the current time.
|
||||
- Set `actorKey` to an anonymous, stable id. Never an email.
|
||||
- Mark test fixtures `isTestData`.
|
||||
- **Update Results from CRM** imports form submissions and applies the campaign's lead rule.
|
||||
- **Import Outcomes** runs every importer the host registered (`MarketingQBitConfig.withOutcomeImporter`) and stores what they report; repeats are dropped by (source, external id). Registered importers are listed in the Configuration Status widget.
|
||||
- **Rebuild Results** recomputes a campaign's **Daily Results** from its raw events. Use it after events were written outside Marketing (direct SQL, a data fix) or if the rollup looks wrong. It is safe to repeat.
|
||||
|
||||
**Agent runs:** **Plan / Draft / Check Claims / Analyze Results with Agent** on a campaign, at most 3 campaigns per run. The run record lists every step and what the model saw. **Cancel Run** stops a run before its next model call. Runs left Running by a restart are marked Failed by the next **Run Due Executions**.
|
||||
|
||||
@@ -98,6 +101,10 @@ These are real limitations of 0.1.0. Nothing here is claimed to be production-sc
|
||||
- **Agent concurrency and the model-call rate are counted in the database**, so they hold across replicas. Concurrency counts `marketing_agent_run` rows in Running (all products); a run records a heartbeat on each model call, and a Running run silent for `maxRunSeconds` plus 60 seconds is marked Failed ("Stopped responding; the server may have restarted.") before the count. The rate counts model-call steps in the last 60 seconds; at the limit a run waits (bounded by `maxRunSeconds`, and Cancel Run still works) instead of failing. The channel hourly rate is computed the same way. Counting and then inserting is still racy: two replicas can both see room, so a limit can be overshot briefly. A conditional-update claim (`ConditionalUpdateAction`, qqq#1178, QQQ 4.1.0-RC.7) will close that window. Hosts add the new `heartbeat_at` column to `marketing_agent_run` (see `docs/ddl/postgres.sql`).
|
||||
- **Agent runs are synchronous** inside the record action, bounded by `maxRunSeconds`. A restart interrupts them: they are marked Failed and can be run again. There is no queue or resume.
|
||||
- **The worker queries due executions page by page** (`workerBatchSize` per run) and scans channel attempts for the hourly rate. That suits hundreds of executions per hour, not bulk email. Bulk outreach is deliberately not supported.
|
||||
- **Measurement is computed in memory** from a campaign's events on every view. Add a rollup table before campaigns have hundreds of thousands of events.
|
||||
- **Measurement is read from a persisted daily rollup** (`marketing_metric_daily`, the **Daily Results** list on a campaign), kept in step by every event insert, edit and delete, so a view reads a few rows per day plus backend aggregates (`SUM` over the rollup, `COUNT_DISTINCT` of `actorKey`), not the events. What remains:
|
||||
- Each event write recomputes its (campaign, day) by reading that day's events, so a very busy day (hundreds of thousands of events) makes writes slow; batch imports are the intended way in. A bulk insert recomputes each affected day once.
|
||||
- The unique-actor count is a `COUNT_DISTINCT` over all of a campaign's real events: fine for hundreds of thousands, but it scans them. Rebuild Results reads events in pages and keeps only the distinct days in memory.
|
||||
- A day is the UTC date of `occurredAt`; the rollup does not follow a reporting time zone.
|
||||
- Importers run synchronously inside the action, bounded by what `fetch` returns; there is no scheduler for them in 0.1.0.
|
||||
- **No live verification** of providers or transports. *Configured* means the settings are present.
|
||||
- **Material Dashboard** (Voyage's current frontend) may not route sub-tables that are not in the app tree. The links in the widgets follow the Next route grammar.
|
||||
@@ -441,6 +441,28 @@ CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_campaign_id ON marketing_o
|
||||
CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_content_id ON marketing_outcome_event (content_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_execution_id ON marketing_outcome_event (execution_id);
|
||||
|
||||
-- A persisted daily rollup of a campaign's outcome events per stage and event type (system-written, recomputable from the raw events).
|
||||
CREATE TABLE IF NOT EXISTS marketing_metric_daily (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
campaign_id INTEGER NOT NULL,
|
||||
product_id INTEGER,
|
||||
day_utc DATE NOT NULL,
|
||||
funnel_stage INTEGER NOT NULL,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
is_test_data BOOLEAN NOT NULL,
|
||||
raw_count INTEGER,
|
||||
unique_actors INTEGER,
|
||||
events_without_actor INTEGER,
|
||||
recomputed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_marketing_metric_daily PRIMARY KEY (id),
|
||||
CONSTRAINT uq_marketing_metric_daily_1 UNIQUE (campaign_id, day_utc, funnel_stage, event_type, is_test_data)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_marketing_metric_daily_campaign_id ON marketing_metric_daily (campaign_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_marketing_metric_daily_product_id ON marketing_metric_daily (product_id);
|
||||
|
||||
-- Runtime switches and limits: agent and execution kill switches, provider, model, budgets, concurrency.
|
||||
CREATE TABLE IF NOT EXISTS marketing_policy (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
|
||||
@@ -18,22 +18,23 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<!-- The provider SPI (com.kingsrook.qbits.assistant.spi) only: no assistant runtime. -->
|
||||
<qbit-assistant.version>0.1.0-RC.12</qbit-assistant.version>
|
||||
<qbit-assistant.version>0.1.0-RC.25</qbit-assistant.version>
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<qbit-email.version>0.1.0-RC.3</qbit-email.version>
|
||||
<qbit-email.version>0.1.0-RC.11</qbit-email.version>
|
||||
<h2.version>2.3.232</h2.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.1.2</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -105,6 +106,12 @@
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- tests only: the local demo server (src/test/.../demo/MarketingDemoServer) -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
@@ -130,6 +137,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-marketing.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
@@ -137,6 +145,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-marketing.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
@@ -200,7 +209,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -194,6 +194,10 @@ TABLES = [
|
||||
F("executionId", "Fk:MarketingExecution"), F("stage", "Enum:FunnelStage", required=True), F("eventType", "String:100", required=True),
|
||||
F("source", "String:100", required=True), F("externalEventId", "String:200", required=True), F("actorKey", "String:200"),
|
||||
F("occurredAt", "Instant"), F("quantity", "Integer"), F("isTestData", "Boolean"), F("note", "String:500")]),
|
||||
("MarketingMetricDaily", "marketing_metric_daily", "A persisted daily rollup of a campaign's outcome events per stage and event type (system-written, recomputable from the raw events).", [
|
||||
F("campaignId", "Fk:MarketingCampaign", required=True), F("productId", "Fk:MarketingProduct"), F("dayUtc", "LocalDate", required=True),
|
||||
F("funnelStage", "Enum:FunnelStage", required=True), F("eventType", "String:100", required=True), F("isTestData", "Boolean", required=True),
|
||||
F("rawCount", "Integer"), F("uniqueActors", "Integer"), F("eventsWithoutActor", "Integer"), F("recomputedAt", "Instant")]),
|
||||
("MarketingPolicy", "marketing_policy", "Runtime switches and limits: agent and execution kill switches, provider, model, budgets, concurrency.", [
|
||||
F("name", "String:64", required=True), F("agentEnabled", "Boolean"), F("executionEnabled", "Boolean"),
|
||||
F("killSwitchReason", "String:255"), F("providerName", "String:64"), F("model", "String:100"),
|
||||
@@ -206,6 +210,7 @@ UNIQUES = {
|
||||
"marketing_content_revision": [["contentId", "revisionNumber"]],
|
||||
"marketing_execution": [["idempotencyKey"]],
|
||||
"marketing_outcome_event": [["source", "externalEventId"]],
|
||||
"marketing_metric_daily": [["campaignId", "dayUtc", "funnelStage", "eventType", "isTestData"]],
|
||||
"marketing_test_delivery": [["idempotencyKey", "recipientKey"]],
|
||||
"marketing_policy": [["name"]],
|
||||
"marketing_channel": [["name"]],
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelPricing;
|
||||
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway;
|
||||
import com.kingsrook.qbits.marketing.measurement.MarketingOutcomeImporter;
|
||||
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -82,8 +83,14 @@ public class MarketingQBitConfig implements QBitConfig
|
||||
private String aiDisclosureText = "Prepared with AI assistance.";
|
||||
|
||||
private Integer staleSourceDays = 90;
|
||||
private Integer staleChannelRulesDays = 90;
|
||||
private transient MarketingCrmGateway crmGateway;
|
||||
|
||||
/** Sources that are Marketing's own and cannot be an importer's name. */
|
||||
private static final List<String> RESERVED_SOURCES = List.of("marketing", "crm");
|
||||
|
||||
private transient List<MarketingOutcomeImporter> outcomeImporters = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -101,6 +108,7 @@ public class MarketingQBitConfig implements QBitConfig
|
||||
"agent limits (maxToolCallsPerRun, maxModelCallsPerRun, maxRunSeconds, maxConcurrentAgentRuns) must be positive", errors);
|
||||
assertCondition(positive(defaultMaxAttempts) && positive(workerBatchSize) && positive(leaseSeconds) && positive(maxRecipientsPerExecution),
|
||||
"execution limits (defaultMaxAttempts, workerBatchSize, leaseSeconds, maxRecipientsPerExecution) must be positive", errors);
|
||||
assertCondition(positive(staleChannelRulesDays), "staleChannelRulesDays must be positive", errors);
|
||||
if(StringUtils.hasContent(providerName))
|
||||
{
|
||||
assertCondition(modelProviders.containsKey(providerName), "providerName [" + providerName + "] is not one of the registered model providers " + modelProviders.keySet(), errors);
|
||||
@@ -118,6 +126,17 @@ public class MarketingQBitConfig implements QBitConfig
|
||||
names.add(transport.getName());
|
||||
}
|
||||
}
|
||||
|
||||
List<String> importerNames = new ArrayList<>();
|
||||
for(MarketingOutcomeImporter importer : outcomeImporters)
|
||||
{
|
||||
if(assertCondition(importer != null && StringUtils.hasContent(importer.getName()), "every outcome importer needs a name", errors))
|
||||
{
|
||||
assertCondition(!importerNames.contains(importer.getName()), "duplicate outcome importer name [" + importer.getName() + "]", errors);
|
||||
assertCondition(!RESERVED_SOURCES.contains(importer.getName()), "outcome importer name [" + importer.getName() + "] is reserved (it is the source of Marketing's own events)", errors);
|
||||
importerNames.add(importer.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +187,27 @@ public class MarketingQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register an outcome importer (run by the Import Outcomes campaign action).
|
||||
*******************************************************************************/
|
||||
public MarketingQBitConfig withOutcomeImporter(MarketingOutcomeImporter importer)
|
||||
{
|
||||
this.outcomeImporters.add(importer);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outcomeImporters
|
||||
*******************************************************************************/
|
||||
public List<MarketingOutcomeImporter> getOutcomeImporters()
|
||||
{
|
||||
return (this.outcomeImporters);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName
|
||||
*******************************************************************************/
|
||||
@@ -752,6 +792,28 @@ public class MarketingQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for staleChannelRulesDays: how long a channel's rulesVerifiedDate
|
||||
** stays current before Check Readiness asks for the rules to be re-read.
|
||||
*******************************************************************************/
|
||||
public Integer getStaleChannelRulesDays()
|
||||
{
|
||||
return (this.staleChannelRulesDays);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for staleChannelRulesDays
|
||||
*******************************************************************************/
|
||||
public MarketingQBitConfig withStaleChannelRulesDays(Integer staleChannelRulesDays)
|
||||
{
|
||||
this.staleChannelRulesDays = staleChannelRulesDays;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for staleSourceDays
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
@@ -46,7 +47,7 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
/*******************************************************************************
|
||||
** Produces the Marketing QBit into a host application:
|
||||
**
|
||||
** - 18 standard tables (marketing_*), their joins, child-list widgets and
|
||||
** - 19 standard tables (marketing_*), their joins, child-list widgets and
|
||||
** possible-value sources, and the optional product security key type;
|
||||
** - the record actions (streamed ETL) and two table actions;
|
||||
** - the Marketing app, whose home is the Marketing dashboard;
|
||||
@@ -70,6 +71,7 @@ public class MarketingQBitProducer implements QBitMetaDataProducer<MarketingQBit
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-marketing";
|
||||
public static final String VERSION = loadVersion();
|
||||
public static final String HELP_BUNDLE = "help/qbit-marketing.yaml";
|
||||
|
||||
private MarketingQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
@@ -200,6 +202,11 @@ public class MarketingQBitProducer implements QBitMetaDataProducer<MarketingQBit
|
||||
}
|
||||
|
||||
qInstance.addApp(builder.buildApp(MarketingWidgets.HOME_WIDGETS));
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// help: register the bundle with core; registration is metadata-only and //
|
||||
// core's startup sync applies it on hosts that have the helpContent table //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
HelpBundles.register(qInstance, HELP_BUNDLE, MarketingQBitProducer.class.getClassLoader());
|
||||
|
||||
////////////////////////////////////
|
||||
// runtime and kernel tools //
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.util.concurrent.locks.ReentrantLock;
|
||||
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway;
|
||||
import com.kingsrook.qbits.marketing.email.QbitEmailTransport;
|
||||
import com.kingsrook.qbits.marketing.measurement.MarketingOutcomeImporter;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingPolicy;
|
||||
import com.kingsrook.qbits.marketing.transport.ControlledTestTransport;
|
||||
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
|
||||
@@ -318,6 +319,17 @@ public class MarketingRuntime implements QSupplementalInstanceMetaData
|
||||
list.add(new IntegrationStatus("CRM", gateway.getName(), gateway.isAvailable() ? "Available" : "Not available",
|
||||
gateway.isAvailable() ? "Uses the CRM's own contact, consent, list and activity tables." : "The CRM's tables are not in this application."));
|
||||
}
|
||||
|
||||
if(config.getOutcomeImporters().isEmpty())
|
||||
{
|
||||
list.add(new IntegrationStatus("Outcome importer", "(none)", "Not configured",
|
||||
"No importer: outcomes are recorded by hand, through the API or by the CRM import, so other metrics stay Not measured."));
|
||||
}
|
||||
for(MarketingOutcomeImporter importer : config.getOutcomeImporters())
|
||||
{
|
||||
list.add(new IntegrationStatus("Outcome importer", importer.getName(), "Registered (not verified by a live call)",
|
||||
"Runs from Import Outcomes on a campaign; events are deduplicated by source and external id."));
|
||||
}
|
||||
return (list);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingInternalWrite;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
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;
|
||||
@@ -33,6 +34,8 @@ import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
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.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
@@ -171,6 +174,38 @@ public final class MarketingStore
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete records by primary key. Errors become exceptions.
|
||||
*******************************************************************************/
|
||||
public static void delete(String tableName, List<Serializable> ids) throws QException
|
||||
{
|
||||
if(ids == null || ids.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
DeleteOutput output = new DeleteAction().execute(new DeleteInput(tableName).withPrimaryKeys(ids));
|
||||
if(CollectionUtils.nullSafeHasContents(output.getRecordsWithErrors()))
|
||||
{
|
||||
throw (new QUserFacingException(output.getRecordsWithErrors().get(0).getErrors().stream().map(e -> e.getMessage()).collect(Collectors.joining("; "))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete from a system-written table.
|
||||
*******************************************************************************/
|
||||
public static void deleteInternal(String tableName, List<Serializable> ids) throws QException
|
||||
{
|
||||
MarketingInternalWrite.run(() ->
|
||||
{
|
||||
delete(tableName, ids);
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert into a system-written table.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -248,6 +248,7 @@ public final class MarketingTools
|
||||
c.put("lead_qualification", Boolean.TRUE.equals(campaign.getLeadQualificationEnabled())
|
||||
? "on: at least " + campaign.getQualifyMinFormSubmissions() + " attributed form submission(s)" + (Boolean.TRUE.equals(campaign.getQualifyRequireEmailConsent()) ? " and email consent" : "")
|
||||
: "off (no leads are qualified)");
|
||||
c.put("lead_qualification_note", campaign.getLeadQualificationNote());
|
||||
|
||||
if(campaign.getAudienceId() != null)
|
||||
{
|
||||
|
||||
+46
-2
@@ -17,6 +17,7 @@
|
||||
package com.kingsrook.qbits.marketing.content;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -39,8 +40,8 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
/*******************************************************************************
|
||||
** Sets productId from campaignId on campaign-owned rows (metrics, outcome
|
||||
** events, findings), so the product security lock (when configured) and
|
||||
** product-level reports cover them. Outcome events also get quantity 1 when
|
||||
** none is given.
|
||||
** product-level reports cover them. Outcome events also get quantity 1, the
|
||||
** current time and isTestData false when none is given.
|
||||
*******************************************************************************/
|
||||
public class ProductFromCampaignCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
@@ -61,6 +62,22 @@ public class ProductFromCampaignCustomizer implements TableCustomizerInterface
|
||||
record.setValue("quantity", 1);
|
||||
}
|
||||
}
|
||||
if("marketing_outcome_event".equals(insertInput.getTableName()))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// an event always has a time (the daily rollup is by its UTC day) //
|
||||
// and is real unless marked as test data //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
if(record.getValue("occurredAt") == null)
|
||||
{
|
||||
record.setValue("occurredAt", Instant.now());
|
||||
}
|
||||
if(record.getValue("isTestData") == null)
|
||||
{
|
||||
record.setValue("isTestData", false);
|
||||
}
|
||||
blankActorToNull(record);
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
@@ -86,6 +103,18 @@ public class ProductFromCampaignCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
fill(record);
|
||||
}
|
||||
if("marketing_outcome_event".equals(updateInput.getTableName()))
|
||||
{
|
||||
if(record.getValues().containsKey("occurredAt") && record.getValue("occurredAt") == null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("An outcome event needs the time it occurred."));
|
||||
}
|
||||
if(record.getValues().containsKey("isTestData") && record.getValue("isTestData") == null)
|
||||
{
|
||||
record.setValue("isTestData", false);
|
||||
}
|
||||
blankActorToNull(record);
|
||||
}
|
||||
if(findings)
|
||||
{
|
||||
guardBlocker(record, oldById.get(ValueUtils.getValueAsInteger(record.getValue("id"))));
|
||||
@@ -127,6 +156,21 @@ public class ProductFromCampaignCustomizer implements TableCustomizerInterface
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A blank actor key is no actor: store null, so the rollup, the report and
|
||||
** the backend's distinct count all agree.
|
||||
*******************************************************************************/
|
||||
private static void blankActorToNull(QRecord record)
|
||||
{
|
||||
String actor = record.getValueString("actorKey");
|
||||
if(record.getValues().containsKey("actorKey") && actor != null && actor.isBlank())
|
||||
{
|
||||
record.setValue("actorKey", null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -40,21 +40,25 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
** compile dependency on the CRM and CRM permissions and locks apply.
|
||||
**
|
||||
** Rules it applies (from qbit-crm's own model):
|
||||
** - Email consent: the contact's most recent crmConsentRecord of type Email
|
||||
** - Email consent: the contact's most recent crm_consent_record of type Email
|
||||
** decides (Granted, and not past its expiry date). No record means no
|
||||
** consent: Marketing does not send unsolicited email.
|
||||
** - Suppression: doNotEmail, an unsubscribeDate, or a list membership that
|
||||
** is not Subscribed.
|
||||
** - Activities are logged as qbit-crm "Note" activities.
|
||||
**
|
||||
** It uses qbit-crm 0.2.0's crm_* snake_case tables (no fallback to the
|
||||
** 0.1.0 camelCase names).
|
||||
*******************************************************************************/
|
||||
public class QbitCrmGateway implements MarketingCrmGateway
|
||||
{
|
||||
public static final String CONTACT_TABLE = "crmContact";
|
||||
public static final String CONSENT_TABLE = "crmConsentRecord";
|
||||
public static final String LIST_MEMBER_TABLE = "crmEmailListMember";
|
||||
public static final String FORM_SUBMISSION_TABLE = "crmFormSubmission";
|
||||
public static final String ACTIVITY_TABLE = "crmActivity";
|
||||
public static final String ACTIVITY_TYPE_TABLE = "crmActivityType";
|
||||
public static final String CONTACT_TABLE = "crm_contact";
|
||||
public static final String CONSENT_TABLE = "crm_consent_record";
|
||||
public static final String LIST_MEMBER_TABLE = "crm_email_list_member";
|
||||
public static final String FORM_SUBMISSION_TABLE = "crm_form_submission";
|
||||
public static final String ACTIVITY_TABLE = "crm_activity";
|
||||
public static final String ACTIVITY_TYPE_TABLE = "crm_activity_type";
|
||||
|
||||
|
||||
static final Integer CONSENT_TYPE_EMAIL = 1;
|
||||
static final Integer CONSENT_STATUS_GRANTED = 1;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing.measurement;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One outcome reported by a MarketingOutcomeImporter.
|
||||
**
|
||||
** @param stage the funnel stage (required)
|
||||
** @param eventType the event type, matching a metric of the measurement plan (required)
|
||||
** @param externalEventId the importer's own id for the outcome, stable across fetches (required)
|
||||
** @param actorKey an anonymous, stable actor id (null when unknown)
|
||||
** @param occurredAt when it happened (null: when it was imported)
|
||||
** @param quantity how many (null: 1)
|
||||
** @param isTestData true for a fixture (kept out of the real numbers)
|
||||
** @param note a short note (null for none)
|
||||
*******************************************************************************/
|
||||
public record ImportedOutcome(FunnelStage stage, String eventType, String externalEventId, String actorKey, Instant occurredAt, Integer quantity,
|
||||
Boolean isTestData, String note)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing.measurement;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A source of outcome events (an analytics export, a store listing, a package
|
||||
** registry, ...) that the host registers with
|
||||
** MarketingQBitConfig.withOutcomeImporter(...). The Import Outcomes campaign
|
||||
** action runs every registered importer.
|
||||
**
|
||||
** The qbit ships no concrete network importer: there is no live analytics
|
||||
** provider in 0.1.0, and nothing is "measured" until one records events.
|
||||
**
|
||||
** An importer only reports what it observed; Marketing stores each outcome
|
||||
** through the normal insert path, so the (source, external id) dedupe applies:
|
||||
** the source is the importer's name, and an outcome fetched again is dropped,
|
||||
** not counted twice. So fetch may safely return events it returned before.
|
||||
*******************************************************************************/
|
||||
public interface MarketingOutcomeImporter
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** The importer's name: unique among importers, and the "source" of every
|
||||
** event it imports (so keep it stable). "marketing" and "crm" are reserved.
|
||||
*******************************************************************************/
|
||||
String getName();
|
||||
|
||||
/*******************************************************************************
|
||||
** The outcomes of a campaign observed at or after since (null: all it can
|
||||
** provide). Marketing passes the occurredAt of the newest event this
|
||||
** importer already recorded for the campaign. Return an empty list when
|
||||
** there is nothing. Never return personal data: the actorKey must be an
|
||||
** anonymous, stable id.
|
||||
*******************************************************************************/
|
||||
List<ImportedOutcome> fetch(QRecord campaign, Instant since) throws QException;
|
||||
|
||||
}
|
||||
@@ -18,20 +18,32 @@ package com.kingsrook.qbits.marketing.measurement;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.marketing.MarketingStore;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingMetric;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingMetricDaily;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
|
||||
import com.kingsrook.qbits.marketing.model.enums.CountMode;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
|
||||
import com.kingsrook.qbits.marketing.model.enums.InstrumentationStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
||||
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.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -46,6 +58,13 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
** - Stages with no metric say so.
|
||||
** - Test data (controlled test transport, fixtures) is reported apart and
|
||||
** never mixed into real numbers.
|
||||
**
|
||||
** The numbers come from the persisted daily rollup (marketing_metric_daily,
|
||||
** kept by MeasurementRollupService) and from backend aggregates, never from
|
||||
** loading the campaign's events: raw counts are summed from the rollup, and
|
||||
** a campaign-wide unique-actor count (which cannot be summed from days) is a
|
||||
** COUNT_DISTINCT of actorKey grouped by stage and event type, run in the
|
||||
** backend.
|
||||
*******************************************************************************/
|
||||
public class MeasurementReport
|
||||
{
|
||||
@@ -89,21 +108,43 @@ public class MeasurementReport
|
||||
{
|
||||
MeasurementReport report = new MeasurementReport();
|
||||
List<QRecord> metrics = MarketingStore.queryBy(MarketingMetric.TABLE_NAME, "campaignId", campaignId);
|
||||
List<QRecord> events = MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "campaignId", campaignId);
|
||||
|
||||
List<QRecord> realEvents = new ArrayList<>();
|
||||
for(QRecord event : events)
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// test events are counted apart; everything else is real data //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
report.testEvents = MarketingStore.count(MarketingOutcomeEvent.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId), new QFilterCriteria("isTestData", QCriteriaOperator.EQUALS, true)));
|
||||
|
||||
QQueryFilter realEventFilter = new QQueryFilter(
|
||||
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId), new QFilterCriteria("isTestData", QCriteriaOperator.EQUALS, false));
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// real events per stage and type (backend COUNT; for the totals and //
|
||||
// for events outside the plan) //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
Aggregate eventCount = new Aggregate("id", AggregateOperator.COUNT);
|
||||
Map<String, AggregateResult> events = aggregate(MarketingOutcomeEvent.TABLE_NAME, "stage", realEventFilter, eventCount);
|
||||
for(AggregateResult result : events.values())
|
||||
{
|
||||
if(Boolean.TRUE.equals(event.getValueBoolean("isTestData")))
|
||||
{
|
||||
report.testEvents++;
|
||||
}
|
||||
else
|
||||
{
|
||||
realEvents.add(event);
|
||||
}
|
||||
report.realEvents += asInt(result.getAggregateValue(eventCount));
|
||||
}
|
||||
report.realEvents = realEvents.size();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// raw counts and events without an actor: summed from the daily rollup //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
Aggregate rawCount = new Aggregate("rawCount", AggregateOperator.SUM);
|
||||
Aggregate eventsWithoutActor = new Aggregate("eventsWithoutActor", AggregateOperator.SUM);
|
||||
Map<String, AggregateResult> rollup = aggregate(MarketingMetricDaily.TABLE_NAME, "funnelStage", new QQueryFilter(
|
||||
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId), new QFilterCriteria("isTestData", QCriteriaOperator.EQUALS, false)), rawCount, eventsWithoutActor);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// unique actors over the whole campaign: COUNT_DISTINCT in the backend //
|
||||
// (a blank actor is no actor) //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
Aggregate uniqueActors = new Aggregate("actorKey", AggregateOperator.COUNT_DISTINCT);
|
||||
Map<String, AggregateResult> actors = aggregate(MarketingOutcomeEvent.TABLE_NAME, "stage", new QQueryFilter(
|
||||
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId), new QFilterCriteria("isTestData", QCriteriaOperator.EQUALS, false),
|
||||
new QFilterCriteria("actorKey", QCriteriaOperator.IS_NOT_BLANK)), uniqueActors);
|
||||
|
||||
Set<FunnelStage> stagesWithMetrics = new HashSet<>();
|
||||
Set<String> plannedTypes = new HashSet<>();
|
||||
@@ -124,26 +165,12 @@ public class MeasurementReport
|
||||
continue;
|
||||
}
|
||||
|
||||
int raw = 0;
|
||||
int noActor = 0;
|
||||
Set<String> actors = new LinkedHashSet<>();
|
||||
for(QRecord event : realEvents)
|
||||
{
|
||||
if(stage.getId().equals(event.getValueInteger("stage")) && metric.getEventType().equals(event.getValueString("eventType")))
|
||||
{
|
||||
Integer quantity = event.getValueInteger("quantity");
|
||||
raw += quantity == null ? 1 : quantity;
|
||||
String actor = event.getValueString("actorKey");
|
||||
if(actor == null || actor.isBlank())
|
||||
{
|
||||
noActor++;
|
||||
}
|
||||
else
|
||||
{
|
||||
actors.add(actor);
|
||||
}
|
||||
}
|
||||
}
|
||||
String key = key(stage.getId(), metric.getEventType());
|
||||
AggregateResult rollupRow = rollup.get(key);
|
||||
AggregateResult actorRow = actors.get(key);
|
||||
int raw = rollupRow == null ? 0 : asInt(rollupRow.getAggregateValue(rawCount));
|
||||
int noActor = rollupRow == null ? 0 : asInt(rollupRow.getAggregateValue(eventsWithoutActor));
|
||||
int actorsCount = actorRow == null ? 0 : asInt(actorRow.getAggregateValue(uniqueActors));
|
||||
|
||||
String note = instrumentation == InstrumentationStatus.PARTIAL ? "Partially instrumented: a lower bound." : null;
|
||||
if(instrumentation == InstrumentationStatus.PARTIAL)
|
||||
@@ -154,7 +181,7 @@ public class MeasurementReport
|
||||
{
|
||||
note = (note == null ? "" : note + " ") + noActor + " event(s) have no actor and are not in the unique count.";
|
||||
}
|
||||
report.rows.add(new Row(stage, metric.getName(), metric.getEventType(), countMode, instrumentation, metric.getTarget(), raw, actors.size(), noActor, note));
|
||||
report.rows.add(new Row(stage, metric.getName(), metric.getEventType(), countMode, instrumentation, metric.getTarget(), raw, actorsCount, noActor, note));
|
||||
}
|
||||
|
||||
for(FunnelStage stage : FunnelStage.values())
|
||||
@@ -166,13 +193,14 @@ public class MeasurementReport
|
||||
}
|
||||
|
||||
Map<String, Integer> unplanned = new LinkedHashMap<>();
|
||||
for(QRecord event : realEvents)
|
||||
for(Map.Entry<String, AggregateResult> entry : events.entrySet())
|
||||
{
|
||||
FunnelStage stage = FunnelStage.getById(event.getValueInteger("stage"));
|
||||
String key = (stage == null ? "?" : stage.name()) + "/" + event.getValueString("eventType");
|
||||
String[] parts = entry.getKey().split("\\|", 2);
|
||||
FunnelStage stage = FunnelStage.getById(Integer.valueOf(parts[0]));
|
||||
String key = (stage == null ? "?" : stage.name()) + "/" + parts[1];
|
||||
if(!plannedTypes.contains(key))
|
||||
{
|
||||
unplanned.merge(key, 1, Integer::sum);
|
||||
unplanned.merge(key, asInt(entry.getValue().getAggregateValue(eventCount)), Integer::sum);
|
||||
}
|
||||
}
|
||||
unplanned.forEach((key, count) -> report.limitations.add(count + " recorded event(s) of type " + key + " have no metric in the measurement plan."));
|
||||
@@ -188,6 +216,51 @@ public class MeasurementReport
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One backend aggregate over a table, grouped by its stage field and event
|
||||
** type; the results by "stage|eventType".
|
||||
*******************************************************************************/
|
||||
private static Map<String, AggregateResult> aggregate(String tableName, String stageField, QQueryFilter filter, Aggregate... aggregates) throws QException
|
||||
{
|
||||
GroupBy stageGroup = new GroupBy(QFieldType.INTEGER, stageField);
|
||||
GroupBy typeGroup = new GroupBy(QFieldType.STRING, "eventType");
|
||||
AggregateInput input = new AggregateInput(tableName).withFilter(filter).withGroupBy(stageGroup).withGroupBy(typeGroup);
|
||||
for(Aggregate aggregate : aggregates)
|
||||
{
|
||||
input.withAggregate(aggregate);
|
||||
}
|
||||
|
||||
Map<String, AggregateResult> map = new HashMap<>();
|
||||
for(AggregateResult result : new AggregateAction().execute(input).getResults())
|
||||
{
|
||||
map.put(key(ValueUtils.getValueAsInteger(result.getGroupByValue(stageGroup)), ValueUtils.getValueAsString(result.getGroupByValue(typeGroup))), result);
|
||||
}
|
||||
return (map);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String key(Integer stageId, String eventType)
|
||||
{
|
||||
return (stageId + "|" + eventType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An aggregate value as a count (null is none).
|
||||
*******************************************************************************/
|
||||
private static int asInt(java.io.Serializable value)
|
||||
{
|
||||
Integer integer = ValueUtils.getValueAsInteger(value);
|
||||
return (integer == null ? 0 : integer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The report as plain data (for the agent's tool and the widget).
|
||||
*******************************************************************************/
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing.measurement;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.marketing.MarketingStore;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingMetricDaily;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingInternalWrite;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
|
||||
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.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keeps the persisted daily rollup (marketing_metric_daily) of a campaign's
|
||||
** outcome events, so a results view reads a few rows per day instead of every
|
||||
** raw event.
|
||||
**
|
||||
** - A day is the UTC date of the event's occurredAt.
|
||||
** - Recomputing a (campaign, day) reads that day's raw events and makes the
|
||||
** rollup rows match them exactly: rows are inserted or updated by their
|
||||
** unique key (campaign, day, stage, event type, test data) and rows whose
|
||||
** events are gone are deleted. It is idempotent: running it again changes
|
||||
** nothing (a row is rewritten only when its numbers differ).
|
||||
** - Raw count is the sum of quantities; unique actors are the distinct
|
||||
** actorKeys of that day (a campaign-wide unique count cannot be summed from
|
||||
** days, so MeasurementReport counts it in the backend); events without an
|
||||
** actor are counted apart, as in the report.
|
||||
** - The rollup is system-written: only this service writes it, inside
|
||||
** MarketingInternalWrite.
|
||||
*******************************************************************************/
|
||||
public class MeasurementRollupService
|
||||
{
|
||||
private static final int PAGE_SIZE = 1000;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One campaign's day.
|
||||
*******************************************************************************/
|
||||
public record CampaignDay(Integer campaignId, LocalDate day)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a recompute did: the days it covered and the rollup rows they hold.
|
||||
*******************************************************************************/
|
||||
public record Result(int days, int rows)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sums of one (stage, test data, event type) of a day.
|
||||
*******************************************************************************/
|
||||
private static final class Sums
|
||||
{
|
||||
private int rawCount = 0;
|
||||
private int eventsWithoutActor = 0;
|
||||
private final Set<String> actors = new HashSet<>();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The UTC day of an event: its occurredAt (the insert fills it in, so it is
|
||||
** never null for a stored event; createDate is only a safety net).
|
||||
*******************************************************************************/
|
||||
public static LocalDate dayOf(QRecord event)
|
||||
{
|
||||
Instant occurredAt = event.getValueInstant("occurredAt");
|
||||
if(occurredAt == null)
|
||||
{
|
||||
occurredAt = event.getValueInstant("createDate");
|
||||
}
|
||||
return (occurredAt == null ? null : occurredAt.atZone(ZoneOffset.UTC).toLocalDate());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recompute every given (campaign, day), as trusted internal work: this runs
|
||||
** from the customizer of a write that was itself permitted, and from Rebuild
|
||||
** Results after its own permission check.
|
||||
*******************************************************************************/
|
||||
public Result recompute(Collection<CampaignDay> campaignDays) throws QException
|
||||
{
|
||||
Set<CampaignDay> distinct = new LinkedHashSet<>();
|
||||
for(CampaignDay campaignDay : campaignDays)
|
||||
{
|
||||
if(campaignDay.campaignId() != null && campaignDay.day() != null)
|
||||
{
|
||||
distinct.add(campaignDay);
|
||||
}
|
||||
}
|
||||
|
||||
return (MarketingStore.trusted(() -> MarketingInternalWrite.run(() ->
|
||||
{
|
||||
int rows = 0;
|
||||
for(CampaignDay campaignDay : distinct)
|
||||
{
|
||||
rows += recomputeDay(campaignDay.campaignId(), campaignDay.day());
|
||||
}
|
||||
return (new Result(distinct.size(), rows));
|
||||
})));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rebuild the whole rollup of a campaign: every day of its events, and every
|
||||
** day of rollup rows that no longer have events. Returns a one-line summary.
|
||||
** Needs the same permission as Update Results from CRM.
|
||||
*******************************************************************************/
|
||||
public String rebuild(Integer campaignId) throws QException
|
||||
{
|
||||
MarketingPermissions.require("rebuild results", MarketingPermissions.PREPARE);
|
||||
MarketingStore.require(MarketingCampaign.TABLE_NAME, campaignId);
|
||||
|
||||
Result result = MarketingStore.trusted(() ->
|
||||
{
|
||||
Set<CampaignDay> days = new LinkedHashSet<>();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// the days of the raw events, a page at a time (memory is bounded //
|
||||
// by the number of distinct days, not the number of events) //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
int lastId = 0;
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> page = MarketingStore.query(MarketingOutcomeEvent.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId),
|
||||
new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(PAGE_SIZE));
|
||||
for(QRecord event : page)
|
||||
{
|
||||
days.add(new CampaignDay(campaignId, dayOf(event)));
|
||||
lastId = event.getValueInteger("id");
|
||||
}
|
||||
if(page.size() < PAGE_SIZE)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for(QRecord existing : MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId))
|
||||
{
|
||||
days.add(new CampaignDay(campaignId, existing.getValueLocalDate("dayUtc")));
|
||||
}
|
||||
return (recompute(days));
|
||||
});
|
||||
return ("rebuilt " + result.rows() + " daily result row(s) over " + result.days() + " day(s)");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Make one day's rollup rows match its raw events. Returns the number of rows
|
||||
** the day has afterwards. Must run inside MarketingInternalWrite.
|
||||
*******************************************************************************/
|
||||
private int recomputeDay(Integer campaignId, LocalDate day) throws QException
|
||||
{
|
||||
Instant start = day.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
Instant end = day.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
|
||||
Map<String, Sums> sums = new LinkedHashMap<>();
|
||||
for(QRecord event : MarketingStore.query(MarketingOutcomeEvent.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId),
|
||||
new QFilterCriteria("occurredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, start),
|
||||
new QFilterCriteria("occurredAt", QCriteriaOperator.LESS_THAN, end))))
|
||||
{
|
||||
Sums sum = sums.computeIfAbsent(key(event.getValueInteger("stage"), Boolean.TRUE.equals(event.getValueBoolean("isTestData")), event.getValueString("eventType")), k -> new Sums());
|
||||
|
||||
Integer quantity = event.getValueInteger("quantity");
|
||||
sum.rawCount += quantity == null ? 1 : quantity;
|
||||
String actor = event.getValueString("actorKey");
|
||||
if(actor == null || actor.isBlank())
|
||||
{
|
||||
sum.eventsWithoutActor++;
|
||||
}
|
||||
else
|
||||
{
|
||||
sum.actors.add(actor);
|
||||
}
|
||||
}
|
||||
|
||||
QRecord campaign = MarketingStore.get(MarketingCampaign.TABLE_NAME, campaignId);
|
||||
Integer productId = campaign == null ? null : campaign.getValueInteger("productId");
|
||||
|
||||
Map<String, QRecord> existing = new LinkedHashMap<>();
|
||||
for(QRecord row : MarketingStore.query(MarketingMetricDaily.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId),
|
||||
new QFilterCriteria("dayUtc", QCriteriaOperator.EQUALS, day))))
|
||||
{
|
||||
existing.put(key(row.getValueInteger("funnelStage"), Boolean.TRUE.equals(row.getValueBoolean("isTestData")), row.getValueString("eventType")), row);
|
||||
}
|
||||
|
||||
for(Map.Entry<String, Sums> entry : sums.entrySet())
|
||||
{
|
||||
String[] parts = entry.getKey().split("\\|", 3);
|
||||
Sums sum = entry.getValue();
|
||||
QRecord row = existing.remove(entry.getKey());
|
||||
if(row == null)
|
||||
{
|
||||
upsertNew(new MarketingMetricDaily().withCampaignId(campaignId).withProductId(productId).withDayUtc(day)
|
||||
.withFunnelStage(Integer.valueOf(parts[0])).withIsTestData(Boolean.valueOf(parts[1])).withEventType(parts[2])
|
||||
.withRawCount(sum.rawCount).withUniqueActors(sum.actors.size()).withEventsWithoutActor(sum.eventsWithoutActor).withRecomputedAt(Instant.now()));
|
||||
}
|
||||
else if(!Objects.equals(productId, row.getValueInteger("productId")) || !Objects.equals(sum.rawCount, row.getValueInteger("rawCount"))
|
||||
|| !Objects.equals(sum.actors.size(), row.getValueInteger("uniqueActors")) || !Objects.equals(sum.eventsWithoutActor, row.getValueInteger("eventsWithoutActor")))
|
||||
{
|
||||
MarketingStore.update(MarketingMetricDaily.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("productId", productId)
|
||||
.withValue("rawCount", sum.rawCount).withValue("uniqueActors", sum.actors.size()).withValue("eventsWithoutActor", sum.eventsWithoutActor)
|
||||
.withValue("recomputedAt", Instant.now()));
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////
|
||||
// rows whose events are gone //
|
||||
///////////////////////////////////
|
||||
List<Serializable> stale = new ArrayList<>();
|
||||
existing.values().forEach(row -> stale.add(row.getValue("id")));
|
||||
MarketingStore.delete(MarketingMetricDaily.TABLE_NAME, stale);
|
||||
return (sums.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a new rollup row. If a concurrent recompute inserted the same key
|
||||
** first (the unique key rejects ours), update that row instead.
|
||||
*******************************************************************************/
|
||||
private void upsertNew(MarketingMetricDaily row) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
MarketingStore.insert(row);
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
List<QRecord> winners = MarketingStore.query(MarketingMetricDaily.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, row.getCampaignId()),
|
||||
new QFilterCriteria("dayUtc", QCriteriaOperator.EQUALS, row.getDayUtc()),
|
||||
new QFilterCriteria("funnelStage", QCriteriaOperator.EQUALS, row.getFunnelStage()),
|
||||
new QFilterCriteria("eventType", QCriteriaOperator.EQUALS, row.getEventType()),
|
||||
new QFilterCriteria("isTestData", QCriteriaOperator.EQUALS, row.getIsTestData())));
|
||||
if(winners.isEmpty())
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
MarketingStore.update(MarketingMetricDaily.TABLE_NAME, new QRecord().withValue("id", winners.get(0).getValue("id")).withValue("productId", row.getProductId())
|
||||
.withValue("rawCount", row.getRawCount()).withValue("uniqueActors", row.getUniqueActors()).withValue("eventsWithoutActor", row.getEventsWithoutActor())
|
||||
.withValue("recomputedAt", row.getRecomputedAt()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The map key of a rollup row: stage, test data, then the event type (last,
|
||||
** so the type may contain the separator).
|
||||
*******************************************************************************/
|
||||
private static String key(Integer stage, boolean isTestData, String eventType)
|
||||
{
|
||||
return (stage + "|" + isTestData + "|" + eventType);
|
||||
}
|
||||
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing.measurement;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.marketing.MarketingStore;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keeps the daily rollup in step with the raw outcome events: after every
|
||||
** insert, update and delete of events - from the table, the QQQ API, the CRM
|
||||
** import, an importer or the execution worker - it recomputes the affected
|
||||
** (campaign, day)s (MeasurementRollupService). An update recomputes both the
|
||||
** old and the new day, so an event moved to another day or campaign leaves
|
||||
** nothing behind.
|
||||
*******************************************************************************/
|
||||
public class OutcomeEventRollupCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
|
||||
{
|
||||
Set<MeasurementRollupService.CampaignDay> days = new LinkedHashSet<>();
|
||||
for(QRecord record : CollectionUtils.nonNullList(records))
|
||||
{
|
||||
if(!CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
{
|
||||
add(days, record);
|
||||
}
|
||||
}
|
||||
new MeasurementRollupService().recompute(days);
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
Map<Serializable, QRecord> oldById = oldRecordListToMap("id", oldRecordList).orElse(Map.of());
|
||||
|
||||
Set<MeasurementRollupService.CampaignDay> days = new LinkedHashSet<>();
|
||||
for(QRecord record : CollectionUtils.nonNullList(records))
|
||||
{
|
||||
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// the stored row after the update (the record may carry only the //
|
||||
// changed fields), and the row as it was //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
add(days, MarketingStore.get(MarketingOutcomeEvent.TABLE_NAME, record.getValue("id")));
|
||||
add(days, oldById.get(record.getValue("id")));
|
||||
}
|
||||
new MeasurementRollupService().recompute(days);
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
Set<MeasurementRollupService.CampaignDay> days = new LinkedHashSet<>();
|
||||
for(QRecord record : CollectionUtils.nonNullList(records))
|
||||
{
|
||||
if(!CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
{
|
||||
add(days, record);
|
||||
}
|
||||
}
|
||||
new MeasurementRollupService().recompute(days);
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void add(Set<MeasurementRollupService.CampaignDay> days, QRecord event)
|
||||
{
|
||||
if(event != null && event.getValueInteger("campaignId") != null && MeasurementRollupService.dayOf(event) != null)
|
||||
{
|
||||
days.add(new MeasurementRollupService.CampaignDay(event.getValueInteger("campaignId"), MeasurementRollupService.dayOf(event)));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing.measurement;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.marketing.MarketingRuntime;
|
||||
import com.kingsrook.qbits.marketing.MarketingStore;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
|
||||
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.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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs a campaign's registered MarketingOutcomeImporters and stores what they
|
||||
** report as outcome events, through the normal insert path: the source is the
|
||||
** importer's name, so an outcome fetched twice is stored once (the unique
|
||||
** (source, external id)). The daily rollup follows from the insert.
|
||||
**
|
||||
** One failing importer does not stop the others; the summary names it and the
|
||||
** action reports the campaign as not fully done.
|
||||
*******************************************************************************/
|
||||
public class OutcomeImportService
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(OutcomeImportService.class);
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Why nothing can be imported (no importer is registered), or null.
|
||||
*******************************************************************************/
|
||||
public String blocker() throws QException
|
||||
{
|
||||
return (MarketingRuntime.current().getConfig().getOutcomeImporters().isEmpty() ? "no outcome importer is registered" : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entry point: runs as trusted internal work after its permission check.
|
||||
** Returns a one-line summary.
|
||||
*******************************************************************************/
|
||||
public String importOutcomes(Integer campaignId) throws QException
|
||||
{
|
||||
MarketingPermissions.require("import outcomes", MarketingPermissions.PREPARE);
|
||||
return (MarketingStore.trusted(() -> importOutcomesImpl(campaignId)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private String importOutcomesImpl(Integer campaignId) throws QException
|
||||
{
|
||||
List<MarketingOutcomeImporter> importers = MarketingRuntime.current().getConfig().getOutcomeImporters();
|
||||
if(importers.isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("No outcome importer is registered in this application."));
|
||||
}
|
||||
|
||||
QRecord campaign = MarketingStore.require(MarketingCampaign.TABLE_NAME, campaignId);
|
||||
List<String> parts = new ArrayList<>();
|
||||
boolean failed = false;
|
||||
for(MarketingOutcomeImporter importer : importers)
|
||||
{
|
||||
try
|
||||
{
|
||||
parts.add(importer.getName() + ": " + importOne(importer, campaign));
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
failed = true;
|
||||
LOG.warn("Outcome importer failed", e, logPair("importer", importer.getName()), logPair("campaignId", campaignId));
|
||||
parts.add(importer.getName() + ": failed (" + (e instanceof QUserFacingException ? e.getMessage() : "see the server log") + ")");
|
||||
}
|
||||
}
|
||||
|
||||
String summary = String.join("; ", parts);
|
||||
if(failed)
|
||||
{
|
||||
throw (new QUserFacingException(summary));
|
||||
}
|
||||
return (summary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private String importOne(MarketingOutcomeImporter importer, QRecord campaign) throws QException
|
||||
{
|
||||
Integer campaignId = campaign.getValueInteger("id");
|
||||
List<ImportedOutcome> outcomes = importer.fetch(campaign, newestOccurredAt(importer.getName(), campaignId));
|
||||
|
||||
int imported = 0;
|
||||
int duplicates = 0;
|
||||
int invalid = 0;
|
||||
for(ImportedOutcome outcome : outcomes == null ? List.<ImportedOutcome>of() : outcomes)
|
||||
{
|
||||
if(outcome == null || outcome.stage() == null || !StringUtils.hasContent(outcome.eventType()) || !StringUtils.hasContent(outcome.externalEventId()))
|
||||
{
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(MarketingStore.count(MarketingOutcomeEvent.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("source", QCriteriaOperator.EQUALS, importer.getName()),
|
||||
new QFilterCriteria("externalEventId", QCriteriaOperator.EQUALS, outcome.externalEventId()))) > 0)
|
||||
{
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
MarketingStore.insert(new MarketingOutcomeEvent()
|
||||
.withCampaignId(campaignId)
|
||||
.withStage(outcome.stage().getId())
|
||||
.withEventType(outcome.eventType())
|
||||
.withSource(importer.getName())
|
||||
.withExternalEventId(outcome.externalEventId())
|
||||
.withActorKey(outcome.actorKey())
|
||||
.withOccurredAt(outcome.occurredAt() == null ? Instant.now() : outcome.occurredAt())
|
||||
.withQuantity(outcome.quantity() == null ? 1 : outcome.quantity())
|
||||
.withIsTestData(Boolean.TRUE.equals(outcome.isTestData()))
|
||||
.withNote(outcome.note()));
|
||||
imported++;
|
||||
}
|
||||
return (imported + " imported, " + duplicates + " already recorded" + (invalid > 0 ? ", " + invalid + " invalid (skipped)" : ""));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** When the newest event this importer recorded for the campaign occurred, or
|
||||
** null when it has recorded none.
|
||||
*******************************************************************************/
|
||||
private static Instant newestOccurredAt(String importerName, Integer campaignId) throws QException
|
||||
{
|
||||
List<QRecord> newest = MarketingStore.query(MarketingOutcomeEvent.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("campaignId", QCriteriaOperator.EQUALS, campaignId),
|
||||
new QFilterCriteria("source", QCriteriaOperator.EQUALS, importerName))
|
||||
.withOrderBy(new QFilterOrderBy("occurredAt", false)).withLimit(1));
|
||||
return (newest.isEmpty() ? null : newest.get(0).getValueInstant("occurredAt"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import java.util.Set;
|
||||
import com.kingsrook.qbits.marketing.MarketingQBitConfig;
|
||||
import com.kingsrook.qbits.marketing.content.MarketingContentCustomizer;
|
||||
import com.kingsrook.qbits.marketing.content.ProductFromCampaignCustomizer;
|
||||
import com.kingsrook.qbits.marketing.measurement.OutcomeEventRollupCustomizer;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingAgentRun;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingAgentStep;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingApproval;
|
||||
@@ -40,6 +41,7 @@ import com.kingsrook.qbits.marketing.model.MarketingExecution;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingExecutionAttempt;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingFinding;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingMetric;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingMetricDaily;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingPolicy;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingProduct;
|
||||
@@ -108,11 +110,12 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
** - The nav (the Marketing app) lists only the main tables - products,
|
||||
** campaigns, content, executions, agent runs - and the settings tables.
|
||||
** Sub-tables (audiences, evidence, claims, revisions, approvals, attempts,
|
||||
** test deliveries, agent steps, findings, metrics, outcome events) appear
|
||||
** test deliveries, agent steps, findings, metrics, outcome events, daily
|
||||
** results) appear
|
||||
** as child lists on their parents; they stay reachable by URL.
|
||||
** - System-written tables (revisions, approvals, executions, attempts, test
|
||||
** deliveries, agent runs, agent steps) are read-only in the UI and API and
|
||||
** guarded by MarketingInternalWrite.
|
||||
** deliveries, agent runs, agent steps, daily results) are read-only in the
|
||||
** UI and API and guarded by MarketingInternalWrite.
|
||||
*******************************************************************************/
|
||||
public class MarketingMetaDataBuilder
|
||||
{
|
||||
@@ -128,7 +131,7 @@ public class MarketingMetaDataBuilder
|
||||
/** Written only by Marketing's services. */
|
||||
public static final Set<String> SYSTEM_WRITTEN_TABLE_NAMES = Set.of(MarketingContentRevision.TABLE_NAME, MarketingApproval.TABLE_NAME,
|
||||
MarketingExecution.TABLE_NAME, MarketingExecutionAttempt.TABLE_NAME, MarketingTestDelivery.TABLE_NAME,
|
||||
MarketingAgentRun.TABLE_NAME, MarketingAgentStep.TABLE_NAME);
|
||||
MarketingAgentRun.TABLE_NAME, MarketingAgentStep.TABLE_NAME, MarketingMetricDaily.TABLE_NAME);
|
||||
|
||||
private final MarketingQBitConfig config;
|
||||
|
||||
@@ -199,6 +202,8 @@ public class MarketingMetaDataBuilder
|
||||
List.of("id", "campaignId", "name", "stage", "eventType", "countMode", "instrumentationStatus", "target"), false),
|
||||
new TableSpec(MarketingOutcomeEvent.TABLE_NAME, "Marketing Outcome Event", "insights", MarketingOutcomeEvent.class, "%s %s", List.of("eventType", "externalEventId"),
|
||||
List.of("id", "campaignId", "stage", "eventType", "source", "externalEventId", "occurredAt", "isTestData"), false),
|
||||
new TableSpec(MarketingMetricDaily.TABLE_NAME, "Marketing Metric Daily", "calendar_view_day", MarketingMetricDaily.class, "%s %s", List.of("dayUtc", "eventType"),
|
||||
List.of("id", "campaignId", "dayUtc", "funnelStage", "eventType", "isTestData", "rawCount", "uniqueActors", "eventsWithoutActor"), false),
|
||||
new TableSpec(MarketingPolicy.TABLE_NAME, "Marketing Policy", "policy", MarketingPolicy.class, "%s", List.of("name"),
|
||||
List.of("id", "name", "agentEnabled", "executionEnabled", "providerName", "model"), true)
|
||||
));
|
||||
@@ -246,6 +251,7 @@ public class MarketingMetaDataBuilder
|
||||
child(MarketingCampaign.TABLE_NAME, MarketingFinding.TABLE_NAME, "campaignId", "Findings", false);
|
||||
child(MarketingCampaign.TABLE_NAME, MarketingAgentRun.TABLE_NAME, "campaignId", "Agent Runs", false);
|
||||
child(MarketingCampaign.TABLE_NAME, MarketingOutcomeEvent.TABLE_NAME, "campaignId", "Outcome Events", true);
|
||||
child(MarketingCampaign.TABLE_NAME, MarketingMetricDaily.TABLE_NAME, "campaignId", "Daily Results", false);
|
||||
|
||||
sectionWidget(MarketingContent.TABLE_NAME, MarketingWidgets.CONTENT_PROPOSAL, "Proposed Publication");
|
||||
child(MarketingContent.TABLE_NAME, MarketingContentRevision.TABLE_NAME, "contentId", "Revision History", false);
|
||||
@@ -383,6 +389,7 @@ public class MarketingMetaDataBuilder
|
||||
case MarketingContentRevision.TABLE_NAME -> table.withUniqueKey(new UniqueKey("contentId", "revisionNumber"));
|
||||
case MarketingExecution.TABLE_NAME -> table.withUniqueKey(new UniqueKey("idempotencyKey"));
|
||||
case MarketingOutcomeEvent.TABLE_NAME -> table.withUniqueKey(new UniqueKey("source", "externalEventId"));
|
||||
case MarketingMetricDaily.TABLE_NAME -> table.withUniqueKey(new UniqueKey("campaignId", "dayUtc", "funnelStage", "eventType", "isTestData"));
|
||||
case MarketingTestDelivery.TABLE_NAME -> table.withUniqueKey(new UniqueKey("idempotencyKey", "recipientKey"));
|
||||
default ->
|
||||
{
|
||||
@@ -417,6 +424,17 @@ public class MarketingMetaDataBuilder
|
||||
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, product);
|
||||
}
|
||||
|
||||
if(MarketingOutcomeEvent.TABLE_NAME.equals(spec.name()))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////
|
||||
// the daily rollup follows every insert, edit and delete //
|
||||
////////////////////////////////////////////////////////////////
|
||||
QCodeReference rollup = new QCodeReference(OutcomeEventRollupCustomizer.class);
|
||||
table.withCustomizer(TableCustomizers.POST_INSERT_RECORD, rollup);
|
||||
table.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, rollup);
|
||||
table.withCustomizer(TableCustomizers.POST_DELETE_RECORD, rollup);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// the optional product (tenant) boundary: a record lock on the //
|
||||
// product, for products and every table that carries productId //
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A persisted daily rollup of a campaign's outcome events per stage and event type (system-written, recomputable from the raw events).
|
||||
** Table marketing_metric_daily.
|
||||
*******************************************************************************/
|
||||
public class MarketingMetricDaily extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "marketing_metric_daily";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = MarketingCampaign.TABLE_NAME)
|
||||
private Integer campaignId;
|
||||
|
||||
@QField(possibleValueSourceName = MarketingProduct.TABLE_NAME)
|
||||
private Integer productId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private LocalDate dayUtc;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = FunnelStage.NAME)
|
||||
private Integer funnelStage;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR, isRequired = true)
|
||||
private String eventType;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Boolean isTestData;
|
||||
|
||||
@QField()
|
||||
private Integer rawCount;
|
||||
|
||||
@QField()
|
||||
private Integer uniqueActors;
|
||||
|
||||
@QField()
|
||||
private Integer eventsWithoutActor;
|
||||
|
||||
@QField()
|
||||
private Instant recomputedAt;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for campaignId
|
||||
*******************************************************************************/
|
||||
public Integer getCampaignId()
|
||||
{
|
||||
return (this.campaignId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for campaignId
|
||||
*******************************************************************************/
|
||||
public void setCampaignId(Integer campaignId)
|
||||
{
|
||||
this.campaignId = campaignId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for campaignId
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withCampaignId(Integer campaignId)
|
||||
{
|
||||
this.campaignId = campaignId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for productId
|
||||
*******************************************************************************/
|
||||
public Integer getProductId()
|
||||
{
|
||||
return (this.productId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for productId
|
||||
*******************************************************************************/
|
||||
public void setProductId(Integer productId)
|
||||
{
|
||||
this.productId = productId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for productId
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withProductId(Integer productId)
|
||||
{
|
||||
this.productId = productId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dayUtc
|
||||
*******************************************************************************/
|
||||
public LocalDate getDayUtc()
|
||||
{
|
||||
return (this.dayUtc);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for dayUtc
|
||||
*******************************************************************************/
|
||||
public void setDayUtc(LocalDate dayUtc)
|
||||
{
|
||||
this.dayUtc = dayUtc;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dayUtc
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withDayUtc(LocalDate dayUtc)
|
||||
{
|
||||
this.dayUtc = dayUtc;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for funnelStage
|
||||
*******************************************************************************/
|
||||
public Integer getFunnelStage()
|
||||
{
|
||||
return (this.funnelStage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for funnelStage
|
||||
*******************************************************************************/
|
||||
public void setFunnelStage(Integer funnelStage)
|
||||
{
|
||||
this.funnelStage = funnelStage;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for funnelStage
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withFunnelStage(Integer funnelStage)
|
||||
{
|
||||
this.funnelStage = funnelStage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for eventType
|
||||
*******************************************************************************/
|
||||
public String getEventType()
|
||||
{
|
||||
return (this.eventType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for eventType
|
||||
*******************************************************************************/
|
||||
public void setEventType(String eventType)
|
||||
{
|
||||
this.eventType = eventType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for eventType
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withEventType(String eventType)
|
||||
{
|
||||
this.eventType = eventType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for isTestData
|
||||
*******************************************************************************/
|
||||
public Boolean getIsTestData()
|
||||
{
|
||||
return (this.isTestData);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for isTestData
|
||||
*******************************************************************************/
|
||||
public void setIsTestData(Boolean isTestData)
|
||||
{
|
||||
this.isTestData = isTestData;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for isTestData
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withIsTestData(Boolean isTestData)
|
||||
{
|
||||
this.isTestData = isTestData;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rawCount
|
||||
*******************************************************************************/
|
||||
public Integer getRawCount()
|
||||
{
|
||||
return (this.rawCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for rawCount
|
||||
*******************************************************************************/
|
||||
public void setRawCount(Integer rawCount)
|
||||
{
|
||||
this.rawCount = rawCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rawCount
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withRawCount(Integer rawCount)
|
||||
{
|
||||
this.rawCount = rawCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uniqueActors
|
||||
*******************************************************************************/
|
||||
public Integer getUniqueActors()
|
||||
{
|
||||
return (this.uniqueActors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for uniqueActors
|
||||
*******************************************************************************/
|
||||
public void setUniqueActors(Integer uniqueActors)
|
||||
{
|
||||
this.uniqueActors = uniqueActors;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for uniqueActors
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withUniqueActors(Integer uniqueActors)
|
||||
{
|
||||
this.uniqueActors = uniqueActors;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for eventsWithoutActor
|
||||
*******************************************************************************/
|
||||
public Integer getEventsWithoutActor()
|
||||
{
|
||||
return (this.eventsWithoutActor);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for eventsWithoutActor
|
||||
*******************************************************************************/
|
||||
public void setEventsWithoutActor(Integer eventsWithoutActor)
|
||||
{
|
||||
this.eventsWithoutActor = eventsWithoutActor;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for eventsWithoutActor
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withEventsWithoutActor(Integer eventsWithoutActor)
|
||||
{
|
||||
this.eventsWithoutActor = eventsWithoutActor;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recomputedAt
|
||||
*******************************************************************************/
|
||||
public Instant getRecomputedAt()
|
||||
{
|
||||
return (this.recomputedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recomputedAt
|
||||
*******************************************************************************/
|
||||
public void setRecomputedAt(Instant recomputedAt)
|
||||
{
|
||||
this.recomputedAt = recomputedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recomputedAt
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withRecomputedAt(Instant recomputedAt)
|
||||
{
|
||||
this.recomputedAt = recomputedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public MarketingMetricDaily withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import com.kingsrook.qbits.marketing.agent.MarketingAgentService;
|
||||
import com.kingsrook.qbits.marketing.approval.ApprovalService;
|
||||
import com.kingsrook.qbits.marketing.execution.ExecutionService;
|
||||
import com.kingsrook.qbits.marketing.measurement.CrmResultsService;
|
||||
import com.kingsrook.qbits.marketing.measurement.MeasurementRollupService;
|
||||
import com.kingsrook.qbits.marketing.measurement.OutcomeImportService;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingAgentRun;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingContent;
|
||||
@@ -57,6 +59,8 @@ public final class MarketingActions
|
||||
public static final String AGENT_REVIEW = "marketing_agent_review";
|
||||
public static final String AGENT_ANALYZE = "marketing_agent_analyze";
|
||||
public static final String UPDATE_RESULTS_FROM_CRM = "marketing_update_results_from_crm";
|
||||
public static final String REBUILD_RESULTS = "marketing_rebuild_results";
|
||||
public static final String IMPORT_OUTCOMES = "marketing_import_outcomes";
|
||||
public static final String SUBMIT_FOR_REVIEW = "marketing_submit_for_review";
|
||||
public static final String APPROVE = "marketing_approve";
|
||||
public static final String RETURN_TO_DRAFT = "marketing_return_to_draft";
|
||||
@@ -173,6 +177,12 @@ public final class MarketingActions
|
||||
"campaign(s) will import CRM form submissions and apply their lead rule",
|
||||
r -> r.getValueString("utmCampaign") == null || r.getValueString("utmCampaign").isBlank() ? "no UTM campaign set" : null,
|
||||
r -> new CrmResultsService().updateFromCrm(r.getValueInteger("id"))));
|
||||
actions.add(new SimpleAction(REBUILD_RESULTS, "Rebuild Results", MarketingCampaign.TABLE_NAME, "calendar_view_day", MarketingPermissions.PREPARE_BASE,
|
||||
"campaign(s) will have their daily results rebuilt from the raw outcome events", r -> null,
|
||||
r -> new MeasurementRollupService().rebuild(r.getValueInteger("id"))));
|
||||
actions.add(new SimpleAction(IMPORT_OUTCOMES, "Import Outcomes", MarketingCampaign.TABLE_NAME, "cloud_download", MarketingPermissions.PREPARE_BASE,
|
||||
"campaign(s) will import outcome events from the registered importers (repeats are dropped)", r -> new OutcomeImportService().blocker(),
|
||||
r -> new OutcomeImportService().importOutcomes(r.getValueInteger("id"))));
|
||||
|
||||
//////////////
|
||||
// content //
|
||||
|
||||
@@ -152,6 +152,7 @@ public class ReadinessChecker
|
||||
MarketingProduct product = new MarketingProduct(MarketingStore.require(MarketingProduct.TABLE_NAME, campaign.getProductId()));
|
||||
Integer pid = campaign.getProductId();
|
||||
findings.clear();
|
||||
int channelRulesStaleDays = MarketingRuntime.current().getConfig().getStaleChannelRulesDays();
|
||||
|
||||
ProductMaturity maturity = ProductMaturity.getById(product.getMaturity());
|
||||
CampaignGoal goal = CampaignGoal.getById(campaign.getGoal());
|
||||
@@ -203,11 +204,24 @@ public class ReadinessChecker
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String rulesUrl = channel.getValueString("rulesSourceUrl");
|
||||
String readIt = StringUtils.hasContent(rulesUrl) ? "Read " + rulesUrl + " and record" : "Read the channel's current rules and record";
|
||||
if(AiContentPolicy.UNKNOWN.getId().equals(channel.getValueInteger("aiContentPolicy")) || channel.getValue("rulesVerifiedDate") == null)
|
||||
{
|
||||
add(pid, campaignId, contentRecord.getValueInteger("id"), null, FindingType.CHANNEL_RULE, FindingSeverity.WARNING,
|
||||
"Channel rules not verified: " + channel.getValueString("name"),
|
||||
"The channel's rules (including on AI-generated content and automation) are not recorded as verified.", "Read the channel's current rules and record them on the channel.");
|
||||
"The channel's rules (including on AI-generated content and automation) are not recorded as verified.", readIt + " them on the channel.");
|
||||
}
|
||||
else if(isStale(channel.getValueLocalDate("rulesVerifiedDate"), channelRulesStaleDays))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// same idea as a stale evidence source: rules change, so a check //
|
||||
// older than staleChannelRulesDays has to be repeated //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
add(pid, campaignId, contentRecord.getValueInteger("id"), null, FindingType.CHANNEL_RULE, FindingSeverity.WARNING,
|
||||
"Channel rules may be out of date: " + channel.getValueString("name"),
|
||||
"The channel's rules were last verified " + channel.getValueLocalDate("rulesVerifiedDate") + " (over " + channelRulesStaleDays + " days ago).",
|
||||
readIt + " the date, and change the channel's AI-content and automation settings if its rules changed.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing.security;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Gates a widget on one exact permission name (marketing.read for the home
|
||||
** widgets, marketing.admin.read for the admin-placeable ones). QQQ's widget
|
||||
** rules can only express "<base>.hasAccess", so this checker is how a widget
|
||||
** requires a read permission. Denied widgets are HIDDEN: omitted from the
|
||||
** dashboard rather than shown locked.
|
||||
*******************************************************************************/
|
||||
public class MarketingPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
|
||||
{
|
||||
public static final String PERMISSION_PROPERTY = "permissionName";
|
||||
|
||||
private String permissionName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission rules requiring the named permission; hidden when missing.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules rules(String permissionName)
|
||||
{
|
||||
return (new QPermissionRules()
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withDenyBehavior(DenyBehavior.HIDDEN)
|
||||
.withCustomPermissionChecker(new QCodeReferenceWithProperties(MarketingPermissionChecker.class, Map.of(PERMISSION_PROPERTY, permissionName))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void initialize(QCodeReference codeReference)
|
||||
{
|
||||
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
|
||||
{
|
||||
this.permissionName = ValueUtils.getValueAsString(withProperties.getProperties().get(PERMISSION_PROPERTY));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deny unless the session holds the permission (fail closed). The system
|
||||
** user (scheduler) passes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session instanceof QSystemUserSession)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(permissionName == null || session == null || !session.hasPermission(permissionName))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean handlesBuildAvailablePermission()
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact permission name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType)
|
||||
{
|
||||
AvailablePermission permission = buildBaseAvailablePermission(rules, permissionSubType, metaDataWithName, objectType);
|
||||
return (permission == null || permissionName == null ? null : permission.withName(permissionName));
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,12 @@ package com.kingsrook.qbits.marketing.widgets;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.marketing.MarketingRuntime;
|
||||
import com.kingsrook.qbits.marketing.MarketingStore;
|
||||
@@ -46,11 +48,14 @@ import com.kingsrook.qbits.marketing.model.enums.ContentStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ExecutionStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingPermissionChecker;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
|
||||
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
|
||||
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.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.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;
|
||||
@@ -66,21 +71,26 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Marketing widgets: the app home dashboard (the one Marketing
|
||||
** overview - other apps only link to it) and two record widgets.
|
||||
**
|
||||
** - Home: Needs Attention, Campaigns, Agent Activity, Configuration Status.
|
||||
** - Home: Needs Attention, Campaigns (business content only).
|
||||
** - Admin-placeable (not on the home): Agent Activity (model/tool calls and
|
||||
** tokens) and Configuration Status (provider configuration) are system
|
||||
** content; a host places ADMIN_WIDGETS on its Admin area. They need
|
||||
** marketing.admin.read.
|
||||
** - Campaign record: Results (per stage; "Not measured" is never shown as 0).
|
||||
** - Content record: Proposed Publication (the exact text, destination,
|
||||
** recipients and schedule a reviewer approves).
|
||||
**
|
||||
** All are full-width tables of at most three columns (or one HTML block),
|
||||
** so they read at every width without inner scrolling. All data is real;
|
||||
** with none, they say so. Renderers require marketing.read and read with
|
||||
** the viewer's record locks.
|
||||
** with none, they say so. Home widgets require marketing.read (omitted, never
|
||||
** shown locked, without it) and read with the viewer's record locks.
|
||||
*******************************************************************************/
|
||||
public final class MarketingWidgets
|
||||
{
|
||||
@@ -91,7 +101,14 @@ public final class MarketingWidgets
|
||||
public static final String CAMPAIGN_RESULTS = "marketing_campaign_results";
|
||||
public static final String CONTENT_PROPOSAL = "marketing_content_proposal";
|
||||
|
||||
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS, AGENT_ACTIVITY, CONFIGURATION);
|
||||
private static final DateTimeFormatter WHEN_FORMAT = DateTimeFormatter.ofPattern("MMM d, yyyy h:mm a z", Locale.US);
|
||||
|
||||
public static final List<String> HOME_WIDGETS = List.of(NEEDS_ATTENTION, CAMPAIGNS);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// system content: for a host to place on its Admin area, not the home //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
public static final List<String> ADMIN_WIDGETS = List.of(AGENT_ACTIVITY, CONFIGURATION);
|
||||
|
||||
|
||||
|
||||
@@ -110,12 +127,12 @@ public final class MarketingWidgets
|
||||
public static List<QWidgetMetaData> buildWidgets()
|
||||
{
|
||||
return (List.of(
|
||||
widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class),
|
||||
widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class),
|
||||
widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class),
|
||||
widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class),
|
||||
widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class),
|
||||
widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class)));
|
||||
widget(NEEDS_ATTENTION, "Needs Attention", WidgetType.TABLE, NeedsAttention.class, MarketingPermissions.READ),
|
||||
widget(CAMPAIGNS, "Campaigns", WidgetType.TABLE, Campaigns.class, MarketingPermissions.READ),
|
||||
widget(AGENT_ACTIVITY, "Agent Activity", WidgetType.TABLE, AgentActivity.class, MarketingPermissions.ADMIN_READ),
|
||||
widget(CONFIGURATION, "Configuration Status", WidgetType.TABLE, Configuration.class, MarketingPermissions.ADMIN_READ),
|
||||
widget(CAMPAIGN_RESULTS, "Results", WidgetType.TABLE, CampaignResults.class, MarketingPermissions.READ),
|
||||
widget(CONTENT_PROPOSAL, "Proposed Publication", WidgetType.HTML, ContentProposal.class, MarketingPermissions.READ)));
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +140,7 @@ public final class MarketingWidgets
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer)
|
||||
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer, String permission)
|
||||
{
|
||||
return (new QWidgetMetaData()
|
||||
.withName(name)
|
||||
@@ -131,7 +148,7 @@ public final class MarketingWidgets
|
||||
.withType(type.getType())
|
||||
.withGridColumns(12)
|
||||
.withIsCard(true)
|
||||
.withPermissionRules(MarketingPermissions.hasAccessRules(MarketingPermissions.BASE))
|
||||
.withPermissionRules(MarketingPermissionChecker.rules(permission))
|
||||
.withCodeReference(new QCodeReference(renderer)));
|
||||
}
|
||||
|
||||
@@ -142,16 +159,26 @@ public final class MarketingWidgets
|
||||
*******************************************************************************/
|
||||
abstract static class Base extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The permission this widget needs (rendering is refused without it, behind
|
||||
** the widget's own rules: no "you need ..." text is ever returned).
|
||||
*******************************************************************************/
|
||||
String requiredPermission()
|
||||
{
|
||||
return (MarketingPermissions.READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(MarketingPermissions.READ)))
|
||||
if(!MarketingPermissions.has(requiredPermission()))
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, "<p>You need " + MarketingPermissions.READ + " to see marketing data.</p>")));
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
return (renderFor(input));
|
||||
}
|
||||
@@ -196,6 +223,19 @@ public final class MarketingWidgets
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A date-time for people, in the user's time zone (the session's, else the
|
||||
** instance's), as the record screens show it: "Oct 14, 2026 11:53 AM CDT"
|
||||
** (empty when null).
|
||||
*******************************************************************************/
|
||||
static String when(Object value)
|
||||
{
|
||||
Instant instant = ValueUtils.getValueAsInstant(value);
|
||||
return (instant == null ? "" : WHEN_FORMAT.withZone(ValueUtils.getSessionOrInstanceZoneId()).format(instant));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -242,32 +282,56 @@ public final class MarketingWidgets
|
||||
for(QRecord r : MarketingStore.query(MarketingContent.TABLE_NAME, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, ContentStatus.IN_REVIEW.getId())).withLimit(25)))
|
||||
{
|
||||
rows.add(row("item", link(MarketingContent.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Waiting for review (revision " + r.getValueInteger("revisionNumber") + ")",
|
||||
"since", String.valueOf(r.getValue("modifyDate"))));
|
||||
"since", when(r.getValue("modifyDate"))));
|
||||
}
|
||||
for(QRecord r : MarketingStore.query(MarketingExecution.TABLE_NAME, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN,
|
||||
List.of(ExecutionStatus.BLOCKED.getId(), ExecutionStatus.NEEDS_RECONCILIATION.getId(), ExecutionStatus.FAILED.getId()))).withLimit(25)))
|
||||
{
|
||||
rows.add(row("item", link(MarketingExecution.TABLE_NAME, r.getValue("id"), "Execution #" + r.getValue("id")),
|
||||
"what", ExecutionStatus.getById(r.getValueInteger("status")).getLabel() + ": " + StringUtils.safeTruncate(r.getValueString("statusDetail"), 160, "..."),
|
||||
"since", String.valueOf(r.getValue("modifyDate"))));
|
||||
"since", when(r.getValue("modifyDate"))));
|
||||
}
|
||||
for(QRecord r : MarketingStore.query(MarketingFinding.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("status", QCriteriaOperator.EQUALS, FindingStatus.OPEN.getId()),
|
||||
new QFilterCriteria("severity", QCriteriaOperator.EQUALS, FindingSeverity.BLOCKER.getId())).withLimit(25)))
|
||||
{
|
||||
rows.add(row("item", link(MarketingFinding.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Open blocker finding", "since", String.valueOf(r.getValue("createDate"))));
|
||||
rows.add(row("item", link(MarketingFinding.TABLE_NAME, r.getValue("id"), r.getValueString("title")), "what", "Open blocker finding", "since", when(r.getValue("createDate"))));
|
||||
}
|
||||
for(QRecord r : MarketingStore.query(MarketingAgentRun.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("status", QCriteriaOperator.EQUALS, AgentRunStatus.FAILED.getId()),
|
||||
new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, Instant.now().minus(7, ChronoUnit.DAYS))).withLimit(10)))
|
||||
{
|
||||
rows.add(row("item", link(MarketingAgentRun.TABLE_NAME, r.getValue("id"), "Agent run #" + r.getValue("id")),
|
||||
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", String.valueOf(r.getValue("createDate"))));
|
||||
"what", "Failed: " + StringUtils.safeTruncate(r.getValueString("errorMessage"), 160, "..."), "since", when(r.getValue("createDate"))));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// a channel that cannot send: said in business words (the provider detail is in //
|
||||
// the admin-only Configuration Status widget) //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
for(QRecord r : MarketingStore.query(MarketingChannel.TABLE_NAME, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)).withOrderBy(new QFilterOrderBy("id")).withLimit(50)))
|
||||
{
|
||||
if(StringUtils.hasContent(r.getValueString("transportName")) && !canSend(r.getValueString("transportName")))
|
||||
{
|
||||
rows.add(row("item", link(MarketingChannel.TABLE_NAME, r.getValue("id"), r.getValueString("name")), "what", "Sends on " + r.getValueString("name") + " are paused - ask an admin",
|
||||
"since", ""));
|
||||
}
|
||||
}
|
||||
|
||||
return (new RenderWidgetOutput(table(List.of(column("Item", "item", true), column("What", "what", false), column("Since", "since", false)), rows,
|
||||
"Nothing needs attention.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Can the named transport send (registered, and operational or test-only)?
|
||||
*******************************************************************************/
|
||||
private static boolean canSend(String transportName) throws QException
|
||||
{
|
||||
MarketingTransport transport = MarketingRuntime.current().getTransport(transportName);
|
||||
return (transport != null && transport.getStatus().isUsable());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -320,6 +384,17 @@ public final class MarketingWidgets
|
||||
*******************************************************************************/
|
||||
public static class AgentActivity extends Base
|
||||
{
|
||||
/*******************************************************************************
|
||||
** System content: marketing.admin.read.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
String requiredPermission()
|
||||
{
|
||||
return (MarketingPermissions.ADMIN_READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -361,6 +436,17 @@ public final class MarketingWidgets
|
||||
*******************************************************************************/
|
||||
public static class Configuration extends Base
|
||||
{
|
||||
/*******************************************************************************
|
||||
** System content: marketing.admin.read.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
String requiredPermission()
|
||||
{
|
||||
return (MarketingPermissions.ADMIN_READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -438,9 +524,25 @@ public final class MarketingWidgets
|
||||
new QFilterCriteria("contentId", QCriteriaOperator.EQUALS, contentId),
|
||||
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ApprovalStatus.ACTIVE.getId())));
|
||||
|
||||
String approval = active.isEmpty() ? "Not approved."
|
||||
: "Approved by " + escape(active.get(0).getValueString("approvedByUserId")) + " at " + active.get(0).getValue("approvedAt")
|
||||
String approval;
|
||||
if(!active.isEmpty())
|
||||
{
|
||||
approval = "Approved by " + escape(active.get(0).getValueString("approvedByUserId")) + " on " + when(active.get(0).getValue("approvedAt"))
|
||||
+ (content.getContentHash() != null && content.getContentHash().equals(active.get(0).getValueString("contentHash")) ? " (matches this revision)." : " (does NOT match this revision).");
|
||||
}
|
||||
else
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// an approval an execution used up is not "not approved": say so //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
List<QRecord> consumed = MarketingStore.query(MarketingApproval.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("contentId", QCriteriaOperator.EQUALS, contentId),
|
||||
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ApprovalStatus.CONSUMED.getId()),
|
||||
new QFilterCriteria("contentHash", QCriteriaOperator.EQUALS, content.getContentHash())));
|
||||
approval = consumed.isEmpty() ? "Not approved."
|
||||
: "Approved by " + escape(consumed.get(0).getValueString("approvedByUserId")) + " on " + when(consumed.get(0).getValue("approvedAt"))
|
||||
+ ", and used by an execution. Approve again to send this revision again.";
|
||||
}
|
||||
|
||||
StringBuilder html = new StringBuilder("<div style=\"max-width:100%;overflow-wrap:anywhere\">");
|
||||
html.append("<p><strong>").append(escape(content.getTitle())).append("</strong></p>");
|
||||
@@ -450,7 +552,7 @@ public final class MarketingWidgets
|
||||
item(html, "Call to action", content.getCallToAction());
|
||||
item(html, "Destination", content.getDestinationUrl());
|
||||
item(html, "Recipients", StringUtils.hasContent(content.getRecipientScope()) ? content.getRecipientScope() : "public (no recipient list)");
|
||||
item(html, "Scheduled for", content.getScheduledFor() == null ? "(not set)" : content.getScheduledFor().toString());
|
||||
item(html, "Scheduled for", content.getScheduledFor() == null ? "(not set)" : when(content.getScheduledFor()));
|
||||
item(html, "Revision", content.getRevisionNumber() + " by " + label(AuthorType.getById(content.getAuthorType())));
|
||||
item(html, "Content hash", content.getContentHash() == null ? "" : content.getContentHash().substring(0, 12));
|
||||
item(html, "Status", label(ContentStatus.getById(content.getStatus())));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
bundle: qbit-marketing
|
||||
version: 0.1.0
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:marketing;slot:help
|
||||
@@ -10,74 +10,110 @@ entries:
|
||||
summary: "A product you market, with its facts, links and how ready it is for marketing."
|
||||
content: |
|
||||
The product brief: maturity, tagline, summary, value propositions, license, platforms, latest release, adoption path, official links and the date its facts were last verified. Evidence sources, claims, audiences and campaigns are listed on the product. Check Readiness reviews its evidence, claims, sources and audiences.
|
||||
|
||||
**How rows get created:** add one with New, or run the table action **Load Verified Starter Data** (QQQ, gclo and MarkdownViewr for Windows, each with its sources, claims, audiences, draft campaigns and measurement plan). Loading is idempotent: a product that already exists (by slug) is left alone.
|
||||
- key: table:marketing_campaign;slot:help
|
||||
summary: "A planned push for one product, with its goal, audience, message and how success is measured."
|
||||
content: |
|
||||
Holds the goal, status, audience, objective, messaging, call to action, destination, UTM campaign, schedule, owner, success criteria and lead rule. Content, the measurement plan, findings, agent runs and outcome events are listed on the campaign. The agent actions and Update Results from CRM run from here.
|
||||
|
||||
**How rows get created:** add one with New on a product, or run **Load Verified Starter Data** on Marketing Product, which adds a draft campaign for each starter product. Agent actions on a campaign plan it but never create a campaign.
|
||||
- key: table:marketing_content;slot:help
|
||||
summary: "A proposed publication: the exact text, destination, recipients, schedule and channel that a person approves."
|
||||
content: |
|
||||
Its status is one of: {{values:marketing_content_status}}. Status changes only through actions such as Submit for Review, Approve and Schedule. Every change to an approved field adds a revision, and an edit after approval voids the approval and returns the content to Draft. The Proposed Publication section shows exactly what a reviewer approves.
|
||||
|
||||
**How rows get created:** add one with New on a campaign, or run **Draft with Agent** on a campaign, which creates Draft content that a person must review. Starter data does not create content. Status changes only through the actions.
|
||||
- key: table:marketing_execution;slot:help
|
||||
summary: "One approved item scheduled to be sent, with its status, attempts and result."
|
||||
content: |
|
||||
Schedule creates one execution per approval. It is written only by Marketing's services and is read-only. Act on it with Run Now, Pause, Resume, Cancel, Reconcile with Provider, Mark as Accepted or Mark Not Sent and Retry. Read its Status Detail when it is Blocked or needs reconciliation.
|
||||
|
||||
**How rows get created:** created by the **Schedule** action on approved content, one per approval. Nobody adds one by hand. Empty means nothing has been scheduled.
|
||||
- key: table:marketing_agent_run;slot:help
|
||||
summary: "One run of the marketing agent: what it was asked, what it did and what it cost."
|
||||
content: |
|
||||
Runs start from the agent actions on a campaign and are read-only. Each run lists every step, including what the model saw, and the drafts and findings it created. The agent only prepares work. It never approves, schedules or sends anything.
|
||||
|
||||
**How rows get created:** started by **Plan with Agent**, **Draft with Agent**, **Check Claims with Agent** or **Analyze Results with Agent** on a campaign or product. Empty means the agent has not been asked anything yet.
|
||||
- key: table:marketing_channel;slot:help
|
||||
summary: "A place content can go, with its transport, AI-content rule, automation setting and limits."
|
||||
content: |
|
||||
A channel records its type, transport, whether it is active and allowed to automate, whether AI-written text is allowed, its own rules with the date they were checked, and its sending limits. It is a settings table: it needs marketing.admin.read to view and marketing.admin.write to change.
|
||||
|
||||
**How rows get created:** run **Load Verified Starter Data** on Marketing Product once: it adds the controlled test sink, email to the opted-in CRM list, the project blog and GitHub release notes, with their rules and the date they were checked. Or add one with New (needs marketing.admin.write). Record the rules, their source URL and the date every time you re-read them; Check Readiness warns when that date is older than the stale-channel-rules limit (90 days unless the host changes it).
|
||||
- key: table:marketing_policy;slot:help
|
||||
summary: "The single default row of switches and limits that govern the agent and sending."
|
||||
content: |
|
||||
Holds the kill switches for the agent and for execution, provider and model overrides, the daily token and monthly cost budgets, and the concurrency and tool limits. If the row is missing, or a switch is not explicitly on, the agent and execution are off. It needs marketing.admin.read to view and marketing.admin.write to change.
|
||||
|
||||
**How rows get created:** **Load Verified Starter Data** adds the `default` row (agent and execution on, 1,000,000 agent tokens per day, USD 25 per month, 2 concurrent runs, 24 tool calls per run). Without it the agent and execution stay off. Or add the row `default` with New. Only that row is used.
|
||||
- key: table:marketing_audience;slot:help
|
||||
summary: "A group of people a product is for, with the problem it solves for them."
|
||||
content: |
|
||||
Describes who the product helps: their problem, the task they want to get done, and the evidence for it. A campaign can target one audience. Check Readiness flags a product that has none.
|
||||
|
||||
**How rows get created:** add one with New on a product, or run **Load Verified Starter Data** on Marketing Product, which adds the starter audiences.
|
||||
- key: table:marketing_evidence_source;slot:help
|
||||
summary: "A source, such as a web page or repository, that product claims are checked against."
|
||||
content: |
|
||||
Records the source type, publisher, URL, an excerpt, who verified it and when. Check Readiness flags a source older than the stale-source limit (90 days unless the host changes it). Claims cite a source.
|
||||
|
||||
**How rows get created:** add one with New on a product, or run **Load Verified Starter Data** on Marketing Product, which adds each starter product's sources verified on the date in the file.
|
||||
- key: table:marketing_claim;slot:help
|
||||
summary: "A factual statement about a product, with its source, status and evidence."
|
||||
content: |
|
||||
Its status ({{values:marketing_claim_status}}) says whether it can be stated as fact. Check Readiness flags claims that are unverified, unsourced or disputed, and an agent draft that cites a claim that is not Released raises a Blocker finding.
|
||||
|
||||
**How rows get created:** add one with New on a product, or run **Load Verified Starter Data** on Marketing Product, which adds the verified claims (disputed ones are marked Disputed with the discrepancy written out).
|
||||
- key: table:marketing_content_revision;slot:help
|
||||
summary: "An unchangeable snapshot of a content item as it stood at one revision."
|
||||
content: |
|
||||
A revision is added on every insert and on every change to the approved fields: title, body, destination, call to action, recipient scope, schedule and channel. Revisions are never edited, so the history shows exactly what was proposed and who or what wrote it.
|
||||
|
||||
**How rows get created:** automatically, whenever content is created or an approved field changes. Nobody adds or edits one. Empty means no content exists yet.
|
||||
- key: table:marketing_approval;slot:help
|
||||
summary: "A person's approval of one exact revision of content, bound to its content hash."
|
||||
content: |
|
||||
It stores the hash, revision, destination, recipient scope and schedule that were approved. Its status is one of: {{values:marketing_approval_status}}. A change to the content that alters the hash invalidates the active approval and records why. It is read-only.
|
||||
|
||||
**How rows get created:** created by the **Approve** action on content that is in review. Read-only. Empty means nothing has been approved yet.
|
||||
- key: table:marketing_execution_attempt;slot:help
|
||||
summary: "One try at sending an execution, with its outcome and the provider's receipt."
|
||||
content: |
|
||||
Each attempt records its outcome, the worker, timing, recipient count and the provider's receipt. A failure that certainly sent nothing is retried with growing delays until the execution runs out of attempts. It is read-only.
|
||||
|
||||
**How rows get created:** written automatically each time an execution is tried (Run Now, or the due-executions job when a scheduler is configured). Empty means nothing has been sent or tried.
|
||||
- key: table:marketing_test_delivery;slot:help
|
||||
summary: "What the test transport accepted for an execution, stored here instead of being sent."
|
||||
content: |
|
||||
The controlled test transport stores each payload per recipient and sends nothing outside the application. A repeated idempotency key is counted as a duplicate rather than stored again. Executions that use it are flagged as test data and left out of results. It is read-only.
|
||||
|
||||
**How rows get created:** written automatically by the controlled test transport when an execution on the test channel runs. Empty means nothing has run on the test channel.
|
||||
- key: table:marketing_agent_step;slot:help
|
||||
summary: "One step of an agent run: a model call, a tool call or a deterministic check."
|
||||
content: |
|
||||
A step is one of: {{values:marketing_agent_step_kind}}. Tool steps keep the input and the result exactly as the model saw it, with secrets redacted. It is read-only.
|
||||
|
||||
**How rows get created:** written automatically for every model call, tool call and check inside an agent run. Empty means the agent has not run.
|
||||
- key: table:marketing_finding;slot:help
|
||||
summary: "A problem, gap or recommendation about a product, campaign or content, with a severity."
|
||||
content: |
|
||||
Findings come from Check Readiness, the agent or a person. An open Blocker finding on a content item stops it being approved. Mark Resolved and Dismiss close an open finding, and closing or downgrading a Blocker needs approval authority.
|
||||
|
||||
**How rows get created:** created by **Check Readiness** on a product or campaign, by the agent actions, or by a person with New. Mark Resolved and Dismiss close them. Empty means no check has run yet, or nothing was found.
|
||||
- key: table:marketing_metric;slot:help
|
||||
summary: "A measurement planned for a campaign: its stage, event type and how it is counted."
|
||||
content: |
|
||||
The measurement plan. Each metric names a funnel stage and an event type, counts raw events or unique actors, and says how fully it is instrumented. A metric that is not instrumented shows Not measured, never 0, and a partly instrumented one is a lower bound.
|
||||
|
||||
**How rows get created:** add one with New on a campaign (its measurement plan), or run **Load Verified Starter Data** on Marketing Product, which adds the plan of each starter campaign.
|
||||
- key: table:marketing_outcome_event;slot:help
|
||||
summary: "One recorded result event for a campaign, unique by its source and external id."
|
||||
content: |
|
||||
Events come from importers, the API, Update Results from CRM or are entered by hand. Source plus External Event Id is unique, so a replay is dropped. Events flagged as test data are left out of the numbers.
|
||||
|
||||
**How rows get created:** imported by an outcome importer, posted through the API, added by **Update Results from CRM** on a campaign, or entered by hand. Empty means no results have been recorded yet, so results show as not measured, never zero.
|
||||
- key: table:marketing_product;field:name
|
||||
summary: "The product's display name."
|
||||
content: |
|
||||
@@ -337,7 +373,7 @@ entries:
|
||||
- key: table:marketing_channel;field:rulesVerifiedDate
|
||||
summary: "When someone last read the channel's rules."
|
||||
content: |
|
||||
Check Readiness warns about content whose channel has no verification date, or whose AI-content rule is Unknown. Record the date every time the rules are re-read.
|
||||
Check Readiness warns about content whose channel has no verification date, or whose AI-content rule is Unknown, and again when the date is older than the stale-channel-rules limit (90 days unless the host changes it). Record the date every time the rules are re-read.
|
||||
- key: table:marketing_channel;field:channelType
|
||||
summary: "What kind of channel it is."
|
||||
content: |
|
||||
@@ -357,7 +393,7 @@ entries:
|
||||
- key: table:marketing_channel;field:rulesSourceUrl
|
||||
summary: "Where the channel's rules are published."
|
||||
content: |
|
||||
Link to the page you read when you recorded the rules.
|
||||
Link to the page you read when you recorded the rules. Check Readiness quotes it in its warning when the rules are unverified or out of date, so the reader knows what to re-read, and the agent can see it.
|
||||
- key: table:marketing_channel;field:rulesSummary
|
||||
summary: "A summary of the channel's rules."
|
||||
content: |
|
||||
@@ -1390,3 +1426,65 @@ entries:
|
||||
summary: "Offers policies to choose from."
|
||||
content: |
|
||||
Used by fields that point to a {{label:marketing_policy}} record.
|
||||
- key: table:marketing_metric_daily;slot:help
|
||||
summary: "Daily result totals for a campaign, kept up to date from its outcome events."
|
||||
content: |
|
||||
One row per campaign, UTC day, funnel stage, event type and test-data flag. Marketing rewrites these rows whenever an outcome event is added, changed or deleted, so they are read-only. The campaign Results come from these totals. Rebuild Results recomputes them from the events.
|
||||
|
||||
**How rows get created:** maintained automatically from the outcome events, and recomputed by **Rebuild Results**. Nobody edits it. Empty means there are no outcome events yet.
|
||||
- key: table:marketing_metric_daily;field:campaignId
|
||||
summary: "The campaign these totals belong to."
|
||||
content: |
|
||||
Set by Marketing from the outcome events.
|
||||
- key: table:marketing_metric_daily;field:productId
|
||||
summary: "The campaign's product, used for product-level access."
|
||||
content: |
|
||||
Copied from the campaign so the product record lock applies to these rows.
|
||||
- key: table:marketing_metric_daily;field:dayUtc
|
||||
summary: "The day the events happened, in UTC."
|
||||
content: |
|
||||
Events are grouped by the UTC date of their Occurred At time.
|
||||
- key: table:marketing_metric_daily;field:funnelStage
|
||||
summary: "The funnel stage of the events counted in this row."
|
||||
content: |
|
||||
One of: {{values:marketing_funnel_stage}}. Stages are reported separately and never added together.
|
||||
- key: table:marketing_metric_daily;field:eventType
|
||||
summary: "The event type counted in this row."
|
||||
content: |
|
||||
The same event type as on the outcome events, for example provider_accepted.
|
||||
- key: table:marketing_metric_daily;field:isTestData
|
||||
summary: "Whether this row counts test events, which the Results leave out."
|
||||
content: |
|
||||
Test events are kept in their own rows so they never mix with real results.
|
||||
- key: table:marketing_metric_daily;field:rawCount
|
||||
summary: "The sum of the events' quantities that day."
|
||||
content: |
|
||||
Raw events, not people. One person can be counted many times.
|
||||
- key: table:marketing_metric_daily;field:uniqueActors
|
||||
summary: "How many different actors had events that day."
|
||||
content: |
|
||||
Counted by actor key for this day only. Daily numbers cannot be added up into a campaign total, so the Results count unique actors across the whole campaign separately.
|
||||
- key: table:marketing_metric_daily;field:eventsWithoutActor
|
||||
summary: "Events that day with no actor key, so they are not in the unique count."
|
||||
content: |
|
||||
The Results note these, because unique actors are a lower bound when some events have no actor.
|
||||
- key: table:marketing_metric_daily;field:recomputedAt
|
||||
summary: "When Marketing last recalculated this row."
|
||||
content: |
|
||||
Updated only when the numbers change.
|
||||
- key: widget:marketing_campaign_join_marketing_metric_daily;slot:label
|
||||
summary: "Daily totals of this campaign's outcome events."
|
||||
content: |
|
||||
Read-only. Use Rebuild Results if they ever look out of step with the events.
|
||||
- key: possibleValueSource:marketing_metric_daily;slot:help
|
||||
summary: "Offers daily result rows to choose from."
|
||||
content: |
|
||||
Used by fields that point to a {{label:marketing_metric_daily}} record.
|
||||
- key: process:marketing_rebuild_results;slot:help
|
||||
summary: "Recomputes the daily result totals of the selected campaigns from their outcome events."
|
||||
content: |
|
||||
Safe to run any time: it rewrites only rows whose numbers differ and deletes days with no events left. Needs the same permission as Update Results from CRM.
|
||||
- key: process:marketing_import_outcomes;slot:help
|
||||
summary: "Fetches new outcome events for the selected campaigns from every registered importer."
|
||||
content: |
|
||||
Each importer adds events with its own name as the source, and a repeat of the same external id is skipped. An importer that fails is named in the result and does not stop the others. With no importers registered, nothing is imported.
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing;
|
||||
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingChannel;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingFinding;
|
||||
import com.kingsrook.qbits.marketing.model.enums.AiContentPolicy;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingType;
|
||||
import com.kingsrook.qbits.marketing.readiness.ReadinessChecker;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A channel's rulesVerifiedDate goes stale like an evidence source does:
|
||||
** Check Readiness on a campaign warns when content uses a channel whose rules
|
||||
** were last read more than staleChannelRulesDays ago, and points at the
|
||||
** channel's rulesSourceUrl. The window is configuration (default 90).
|
||||
*******************************************************************************/
|
||||
class ChannelRulesStalenessTest extends BaseMarketingTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Open channel-rule findings for the campaign.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> ruleFindings(Integer campaignId) throws QException
|
||||
{
|
||||
return (MarketingStore.queryBy(MarketingFinding.TABLE_NAME, "campaignId", campaignId).stream()
|
||||
.filter(f -> FindingType.CHANNEL_RULE.getId().equals(f.getValueInteger("findingType")) && FindingStatus.OPEN.getId().equals(f.getValueInteger("status"))).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh rules: no finding. Rules last read 91 days ago: a warning that names
|
||||
** the date and the source URL. Re-reading them (a new date) clears it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void staleChannelRulesRaiseAWarningAndRereadingClearsIt() throws QException
|
||||
{
|
||||
Integer campaignId = campaign(product("stale-rules"));
|
||||
Integer channelId = channel("Forum", ChannelType.COMMUNITY, null, AiContentPolicy.ALLOWED_WITH_DISCLOSURE, false);
|
||||
content(campaignId, channelId, "Body");
|
||||
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesSourceUrl", "https://forum.example/rules"));
|
||||
|
||||
new ReadinessChecker().checkCampaign(campaignId);
|
||||
assertThat(ruleFindings(campaignId)).as("rules verified today").isEmpty();
|
||||
|
||||
LocalDate old = LocalDate.now().minusDays(91);
|
||||
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesVerifiedDate", old));
|
||||
new ReadinessChecker().checkCampaign(campaignId);
|
||||
assertThat(ruleFindings(campaignId)).singleElement().satisfies(f ->
|
||||
{
|
||||
assertThat(f.getValueString("title")).contains("Channel rules may be out of date").contains("Forum");
|
||||
assertThat(f.getValueString("detail")).contains(old.toString()).contains("90 days");
|
||||
assertThat(f.getValueString("recommendation")).contains("https://forum.example/rules");
|
||||
assertThat(f.getValueInteger("severity")).isEqualTo(FindingSeverity.WARNING.getId());
|
||||
});
|
||||
|
||||
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesVerifiedDate", LocalDate.now().minusDays(89)));
|
||||
new ReadinessChecker().checkCampaign(campaignId);
|
||||
assertThat(ruleFindings(campaignId)).as("89 days is still current").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The window is configuration, not a constant.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void windowIsConfigurable() throws QException
|
||||
{
|
||||
config.withStaleChannelRulesDays(7);
|
||||
Integer campaignId = campaign(product("window"));
|
||||
Integer channelId = channel("Blog", ChannelType.BLOG, null, AiContentPolicy.ALLOWED, false);
|
||||
content(campaignId, channelId, "Body");
|
||||
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesVerifiedDate", LocalDate.now().minusDays(10)));
|
||||
new ReadinessChecker().checkCampaign(campaignId);
|
||||
assertThat(ruleFindings(campaignId)).singleElement().satisfies(f -> assertThat(f.getValueString("detail")).contains("7 days"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A channel with no rule date or an Unknown AI policy keeps the existing
|
||||
** "not verified" warning (not the stale one), and it names the source URL.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unverifiedChannelKeepsItsOwnWarning() throws QException
|
||||
{
|
||||
Integer campaignId = campaign(product("unverified"));
|
||||
Integer channelId = channel("Unknown place", ChannelType.COMMUNITY, null, AiContentPolicy.UNKNOWN, false);
|
||||
content(campaignId, channelId, "Body");
|
||||
MarketingStore.update(MarketingChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("rulesSourceUrl", "https://place.example/rules"));
|
||||
new ReadinessChecker().checkCampaign(campaignId);
|
||||
assertThat(ruleFindings(campaignId)).singleElement().satisfies(f ->
|
||||
{
|
||||
assertThat(f.getValueString("title")).contains("Channel rules not verified");
|
||||
assertThat(f.getValueString("recommendation")).contains("https://place.example/rules");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,13 +29,16 @@ import com.kingsrook.qbits.marketing.model.MarketingContent;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingExecution;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingFinding;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingProduct;
|
||||
import com.kingsrook.qbits.marketing.model.enums.AiContentPolicy;
|
||||
import com.kingsrook.qbits.marketing.model.enums.CampaignGoal;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ChannelType;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ClaimStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingSeverity;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FindingType;
|
||||
import com.kingsrook.qbits.marketing.processes.MarketingActions;
|
||||
import com.kingsrook.qbits.marketing.readiness.ReadinessChecker;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
|
||||
import com.kingsrook.qbits.marketing.seed.MarketingStarterData;
|
||||
import com.kingsrook.qbits.marketing.transport.MarketingTransport;
|
||||
import com.kingsrook.qbits.marketing.transport.QqqMessagingEmailTransport;
|
||||
@@ -49,8 +52,11 @@ import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolContext;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolInput;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
@@ -64,6 +70,7 @@ import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -112,11 +119,16 @@ class ComponentsTest extends BaseMarketingTest
|
||||
TableData config = (TableData) render(MarketingWidgets.CONFIGURATION, null).getWidgetData();
|
||||
assertThat(config.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("status"))).isEqualTo("Not configured"))
|
||||
.anySatisfy(r -> assertThat(String.valueOf(r.get("status"))).startsWith("Test only"));
|
||||
assertThat(config.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("integration"))).contains("Outcome importer").contains("(none)"));
|
||||
TableData campaigns = (TableData) render(MarketingWidgets.CAMPAIGNS, null).getWidgetData();
|
||||
assertThat(campaigns.getRows()).hasSize(4);
|
||||
assertThat(campaigns.getColumns()).hasSizeLessThanOrEqualTo(3);
|
||||
assertThat(((TableData) render(MarketingWidgets.AGENT_ACTIVITY, null).getWidgetData()).getNoRowsFoundHTML()).contains("not run yet");
|
||||
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty();
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the starter email channel's transport is configured but not verified: sends paused //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows())
|
||||
.singleElement().satisfies(r -> assertThat(String.valueOf(r.get("what"))).endsWith("are paused - ask an admin"));
|
||||
|
||||
TableData results = (TableData) render(MarketingWidgets.CAMPAIGN_RESULTS, campaignId).getWidgetData();
|
||||
assertThat(results.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("result"))).startsWith("0 unique actors"));
|
||||
@@ -126,8 +138,81 @@ class ComponentsTest extends BaseMarketingTest
|
||||
RawHTML proposal = (RawHTML) render(MarketingWidgets.CONTENT_PROPOSAL, contentId).getWidgetData();
|
||||
assertThat(proposal.getHtml()).contains("<script>").doesNotContain("<script>").contains("Not approved");
|
||||
|
||||
as(user("no-read", com.kingsrook.qbits.marketing.security.MarketingPermissions.APP_ACCESS));
|
||||
assertThat(((RawHTML) render(MarketingWidgets.CAMPAIGNS, null).getWidgetData()).getHtml()).contains("marketing.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The business home lists only business widgets: no configuration status
|
||||
** and no agent activity (token and model usage), which are admin-placeable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHomeHasNoSystemWidgets()
|
||||
{
|
||||
List<String> home = qInstance.getApp("marketing").getWidgets();
|
||||
assertThat(home).containsExactly(MarketingWidgets.NEEDS_ATTENTION, MarketingWidgets.CAMPAIGNS)
|
||||
.doesNotContain(MarketingWidgets.CONFIGURATION, MarketingWidgets.AGENT_ACTIVITY);
|
||||
assertThat(MarketingWidgets.ADMIN_WIDGETS).containsExactly(MarketingWidgets.AGENT_ACTIVITY, MarketingWidgets.CONFIGURATION);
|
||||
assertThat(qInstance.getWidget(MarketingWidgets.CONFIGURATION)).isNotNull();
|
||||
assertThat(qInstance.getWidget(MarketingWidgets.AGENT_ACTIVITY)).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** App access without marketing.read: every read-requiring widget is omitted
|
||||
** (no permission) and never renders a "You need ..." shell. Admins still
|
||||
** get the moved widgets; read-only users do not.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWidgetsAreOmittedWithoutRead() throws QException
|
||||
{
|
||||
List<String> readWidgets = List.of(MarketingWidgets.NEEDS_ATTENTION, MarketingWidgets.CAMPAIGNS, MarketingWidgets.CAMPAIGN_RESULTS, MarketingWidgets.CONTENT_PROPOSAL);
|
||||
List<String> adminWidgets = MarketingWidgets.ADMIN_WIDGETS;
|
||||
|
||||
as(user("no-read", MarketingPermissions.APP_ACCESS));
|
||||
for(String name : CollectionUtils.mergeLists(readWidgets, adminWidgets))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
|
||||
assertThatThrownBy(() -> render(name, null)).as(name).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
as(user("reader", MarketingPermissions.APP_ACCESS, MarketingPermissions.READ));
|
||||
for(String name : readWidgets)
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isTrue();
|
||||
}
|
||||
for(String name : adminWidgets)
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
|
||||
}
|
||||
|
||||
as(admin());
|
||||
for(String name : adminWidgets)
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isTrue();
|
||||
assertThat(render(name, null).getWidgetData()).as(name).isInstanceOf(TableData.class);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A channel that cannot send adds one business-worded row linking to the
|
||||
** channel; nothing when every channel can send.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNeedsAttentionSaysWhenAChannelCannotSend() throws QException
|
||||
{
|
||||
testChannel();
|
||||
assertThat(((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows()).isEmpty();
|
||||
|
||||
Integer blocked = channel("Newsletter", ChannelType.TEST_SINK, "no-such-transport", AiContentPolicy.ALLOWED, true);
|
||||
List<Map<String, Object>> rows = ((TableData) render(MarketingWidgets.NEEDS_ATTENTION, null).getWidgetData()).getRows();
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(String.valueOf(rows.get(0).get("what"))).isEqualTo("Sends on Newsletter are paused - ask an admin");
|
||||
assertThat(String.valueOf(rows.get(0).get("item"))).contains("marketing_channel").contains(String.valueOf(blocked)).contains("Newsletter");
|
||||
assertThat(String.valueOf(rows.get(0))).doesNotContain("transport").doesNotContain("Not configured");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -253,6 +253,14 @@ class EndToEndFlowTest extends BaseMarketingTest
|
||||
assertThat(deliveries.get(0).getValueString("body")).as("exactly the approved text").isEqualTo(body);
|
||||
assertThat(reload(MarketingApproval.TABLE_NAME, approval.getValueInteger("id")).getValueInteger("status")).isEqualTo(ApprovalStatus.CONSUMED.getId());
|
||||
assertThat(reload(MarketingContent.TABLE_NAME, contentId).getValueInteger("status")).isEqualTo(ContentStatus.ACCEPTED.getId());
|
||||
com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput proposalInput = new com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput()
|
||||
.withWidgetMetaData(com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getWidget(com.kingsrook.qbits.marketing.widgets.MarketingWidgets.CONTENT_PROPOSAL));
|
||||
proposalInput.setQueryParams(new java.util.HashMap<>(java.util.Map.of("id", String.valueOf(contentId))));
|
||||
com.kingsrook.qqq.backend.core.context.QContext.getQSession().setValue(com.kingsrook.qqq.backend.core.model.session.QSession.VALUE_KEY_USER_TIMEZONE, "America/Chicago");
|
||||
String proposal =((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) new com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction()
|
||||
.execute(proposalInput).getWidgetData()).getHtml();
|
||||
assertThat(proposal).as("a used-up approval is not shown as not approved").contains("used by an execution").doesNotContain("Not approved")
|
||||
.containsPattern("[A-Z][a-z]{2} [0-9]{1,2}, [0-9]{4} [0-9]{1,2}:[0-9]{2} [AP]M C[DS]T");
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// 7. recorded outcomes: the provider acceptance (test data) and //
|
||||
|
||||
@@ -17,404 +17,108 @@
|
||||
package com.kingsrook.qbits.marketing;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpTarget;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundle;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleLayer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
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.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with
|
||||
** HelpBundleAssertions.forInstance(qInstance).bundles("help/qbit-marketing.yaml").owning(...).assertComplete()
|
||||
** once qqq#1198 ships.
|
||||
**
|
||||
** The help bundle (help/qbit-marketing.yaml) covers everything the qbit
|
||||
** produces, and every entry is sound:
|
||||
** - the app, every marketing_* table, every non-trivial field (all but the
|
||||
** primary key, createDate, modifyDate and hidden fields), every process,
|
||||
** every widget and every possible-value source has an entry;
|
||||
** - every entry key resolves to a real object;
|
||||
** - summaries are at most 120 characters, with no markup;
|
||||
** - every {{...}} reference resolves.
|
||||
**
|
||||
** Each test fails with the full list of missing or broken keys.
|
||||
** produces, and every entry is sound. Core's HelpBundleAssertions checks
|
||||
** that the app, every marketing_* table, every non-trivial field and every
|
||||
** process has help, that every key and {{...}} reference resolves and that
|
||||
** summaries are short plain text. Core does not require widget or
|
||||
** possible-value-source coverage, so those are checked here too. QQQ's
|
||||
** generated bulk table processes are core's to document and are not owned.
|
||||
*******************************************************************************/
|
||||
class HelpBundleCoverageTest extends BaseMarketingTest
|
||||
{
|
||||
private static final String BUNDLE_RESOURCE = "help/qbit-marketing.yaml";
|
||||
|
||||
private static final int MAX_SUMMARY_LENGTH = 120;
|
||||
private static final String MARKUP_CHARACTERS = "<>*_`[]#";
|
||||
|
||||
private static final Pattern REFERENCE = Pattern.compile("\\{\\{([A-Za-z]+):([^}]*)}}");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One bundle entry.
|
||||
*******************************************************************************/
|
||||
private record Entry(String key, String summary, String content)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle header names this qbit, at its layer.
|
||||
** The producer registers exactly this qbit's bundle, at the QBIT layer.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHeader() throws IOException
|
||||
void testRegisteredBundle()
|
||||
{
|
||||
Map<String, Object> bundle = loadBundle();
|
||||
assertThat(bundle.get("bundle")).isEqualTo("qbit-marketing");
|
||||
assertThat(bundle.get("layer")).isEqualTo("QBIT");
|
||||
assertThat(bundle.get("version")).isNotNull();
|
||||
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-marketing");
|
||||
assertThat(bundle.getLayer()).isEqualTo(HelpBundleLayer.QBIT);
|
||||
assertThat(bundle.getVersion()).isEqualTo(MarketingQBitProducer.VERSION);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every object the qbit owns has an entry.
|
||||
** Core coverage gate over everything the qbit owns.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryOwnedObjectHasAnEntry() throws IOException
|
||||
void testCoreCoverageGate()
|
||||
{
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
{
|
||||
if(!keys.add(entry.key()))
|
||||
{
|
||||
problems.add("duplicate entry: " + entry.key());
|
||||
}
|
||||
}
|
||||
|
||||
for(String expected : expectedKeys())
|
||||
{
|
||||
if(!keys.contains(expected))
|
||||
{
|
||||
problems.add("missing entry: " + expected);
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
HelpBundleAssertions.forInstance(qInstance)
|
||||
.bundles(MarketingQBitProducer.HELP_BUNDLE)
|
||||
.owning(HelpBundleCoverageTest::ownedByMarketing)
|
||||
.assertComplete();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every entry key names something that exists.
|
||||
** The qbit owns its app, marketing_* objects and the processes it produces.
|
||||
** QQQ's generated bulk table processes (no source qbit) are core's to document.
|
||||
*******************************************************************************/
|
||||
private static boolean ownedByMarketing(HelpTarget target)
|
||||
{
|
||||
if(target.owner() instanceof QProcessMetaData process && process.getSourceQBitName() == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (target.name().equals("marketing") || target.name().startsWith("marketing_") || target.name().startsWith("Marketing"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every widget and possible-value source has an entry, and every entry has
|
||||
** a summary and content.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryKeyResolves() throws IOException
|
||||
void testWidgetsSourcesAndContent()
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
Map<HelpBundles.EntryKey, HelpBundles.WinningEntry> entries = HelpBundles.of(qInstance).resolveWinningEntries();
|
||||
List<String> problems = new ArrayList<>();
|
||||
TreeSet<String> expected = new TreeSet<>();
|
||||
qInstance.getWidgets().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> expected.add("widget:" + n + ";slot:label"));
|
||||
qInstance.getPossibleValueSources().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> expected.add("possibleValueSource:" + n + ";slot:help"));
|
||||
for(String key : expected)
|
||||
{
|
||||
String problem = resolveKey(entry.key());
|
||||
if(problem != null)
|
||||
if(!entries.containsKey(new HelpBundles.EntryKey(key, "ALL_SCREENS")))
|
||||
{
|
||||
problems.add(entry.key() + ": " + problem);
|
||||
problems.add("missing entry: " + key);
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summaries are short, plain text; every entry has content.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSummariesAndContent() throws IOException
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
HelpBundle bundle = HelpBundles.of(qInstance).getBundles().getFirst();
|
||||
for(HelpBundleEntry entry : bundle.getEntries())
|
||||
{
|
||||
String summary = entry.summary();
|
||||
if(summary == null || summary.isBlank())
|
||||
if(entry.getSummary() == null || entry.getSummary().isBlank())
|
||||
{
|
||||
problems.add(entry.key() + ": no summary");
|
||||
continue;
|
||||
problems.add(entry.getKey() + ": no summary");
|
||||
}
|
||||
if(summary.length() > MAX_SUMMARY_LENGTH)
|
||||
if(entry.getContent() == null || entry.getContent().isBlank())
|
||||
{
|
||||
problems.add(entry.key() + ": summary is " + summary.length() + " characters (limit " + MAX_SUMMARY_LENGTH + ")");
|
||||
}
|
||||
for(char c : MARKUP_CHARACTERS.toCharArray())
|
||||
{
|
||||
if(summary.indexOf(c) >= 0)
|
||||
{
|
||||
problems.add(entry.key() + ": summary contains the markup character " + c);
|
||||
}
|
||||
}
|
||||
if(entry.content() == null || entry.content().isBlank())
|
||||
{
|
||||
problems.add(entry.key() + ": no content");
|
||||
problems.add(entry.getKey() + ": no content");
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every {{...}} reference resolves.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryReferenceResolves() throws IOException
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
for(Entry entry : loadEntries())
|
||||
{
|
||||
for(String text : new String[] { entry.summary(), entry.content() })
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Matcher matcher = REFERENCE.matcher(text);
|
||||
while(matcher.find())
|
||||
{
|
||||
String problem = resolveReference(matcher.group(1), matcher.group(2));
|
||||
if(problem != null)
|
||||
{
|
||||
problems.add(entry.key() + ": {{" + matcher.group(1) + ":" + matcher.group(2) + "}} " + problem);
|
||||
}
|
||||
}
|
||||
if(text.replaceAll(REFERENCE.pattern(), "").contains("{{"))
|
||||
{
|
||||
problems.add(entry.key() + ": malformed {{ reference");
|
||||
}
|
||||
}
|
||||
}
|
||||
assertNoProblems(problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The keys the bundle must contain, from the produced instance.
|
||||
*******************************************************************************/
|
||||
private Set<String> expectedKeys()
|
||||
{
|
||||
Set<String> keys = new TreeSet<>();
|
||||
keys.add("app:" + MarketingMetaDataBuilder.APP_NAME + ";slot:help");
|
||||
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
if(!table.getName().startsWith("marketing_"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
keys.add("table:" + table.getName() + ";slot:help");
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(isNonTrivial(table, field))
|
||||
{
|
||||
keys.add("table:" + table.getName() + ";field:" + field.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// the qbit's own processes (QQQ also generates bulk table processes) //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
for(QProcessMetaData process : qInstance.getProcesses().values())
|
||||
{
|
||||
if(process.getSourceQBitName() != null)
|
||||
{
|
||||
keys.add("process:" + process.getName() + ";slot:help");
|
||||
}
|
||||
}
|
||||
|
||||
qInstance.getWidgets().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> keys.add("widget:" + n + ";slot:label"));
|
||||
qInstance.getPossibleValueSources().keySet().stream().filter(n -> n.startsWith("marketing_")).forEach(n -> keys.add("possibleValueSource:" + n + ";slot:help"));
|
||||
return (keys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every field except the primary key, createDate, modifyDate and hidden ones.
|
||||
*******************************************************************************/
|
||||
private static boolean isNonTrivial(QTableMetaData table, QFieldMetaData field)
|
||||
{
|
||||
String name = field.getName();
|
||||
return (!name.equals(table.getPrimaryKeyField()) && !name.equals("createDate") && !name.equals("modifyDate") && !Boolean.TRUE.equals(field.getIsHidden()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null if the key names a real object, else why not.
|
||||
*******************************************************************************/
|
||||
private String resolveKey(String key)
|
||||
{
|
||||
Map<String, String> parts = new LinkedHashMap<>();
|
||||
for(String part : key.split(";"))
|
||||
{
|
||||
int colon = part.indexOf(':');
|
||||
if(colon < 1)
|
||||
{
|
||||
return ("malformed key part [" + part + "]");
|
||||
}
|
||||
parts.put(part.substring(0, colon), part.substring(colon + 1));
|
||||
}
|
||||
|
||||
if(parts.containsKey("app"))
|
||||
{
|
||||
return (qInstance.getApp(parts.get("app")) == null ? "no such app" : slotProblem(parts, "help"));
|
||||
}
|
||||
if(parts.containsKey("table"))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(parts.get("table"));
|
||||
if(table == null)
|
||||
{
|
||||
return ("no such table");
|
||||
}
|
||||
if(parts.containsKey("field"))
|
||||
{
|
||||
return (table.getFields().containsKey(parts.get("field")) ? null : "no such field");
|
||||
}
|
||||
if(parts.containsKey("section"))
|
||||
{
|
||||
return (table.getSections() != null && table.getSections().stream().map(QFieldSection::getName).anyMatch(parts.get("section")::equals) ? null : "no such section");
|
||||
}
|
||||
return (slotProblem(parts, "help"));
|
||||
}
|
||||
if(parts.containsKey("process"))
|
||||
{
|
||||
QProcessMetaData process = qInstance.getProcess(parts.get("process"));
|
||||
if(process == null)
|
||||
{
|
||||
return ("no such process");
|
||||
}
|
||||
if(parts.containsKey("step"))
|
||||
{
|
||||
return (process.getStep(parts.get("step")) == null ? "no such step" : null);
|
||||
}
|
||||
return (parts.containsKey("field") ? "process fields are not covered by this bundle" : slotProblem(parts, "help"));
|
||||
}
|
||||
if(parts.containsKey("widget"))
|
||||
{
|
||||
return (qInstance.getWidget(parts.get("widget")) == null ? "no such widget" : slotProblem(parts, "label"));
|
||||
}
|
||||
if(parts.containsKey("possibleValueSource"))
|
||||
{
|
||||
return (qInstance.getPossibleValueSource(parts.get("possibleValueSource")) == null ? "no such possible-value source" : slotProblem(parts, "help"));
|
||||
}
|
||||
return ("unsupported key");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The slot must be the expected one.
|
||||
*******************************************************************************/
|
||||
private static String slotProblem(Map<String, String> parts, String expectedSlot)
|
||||
{
|
||||
return (expectedSlot.equals(parts.get("slot")) ? null : "expected slot:" + expectedSlot);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null if the live reference resolves, else why not.
|
||||
*******************************************************************************/
|
||||
private String resolveReference(String kind, String target)
|
||||
{
|
||||
String[] tableAndField = target.split("\\.", 2);
|
||||
QTableMetaData table = qInstance.getTable(tableAndField[0]);
|
||||
QFieldMetaData field = table == null || tableAndField.length < 2 ? null : table.getFields().get(tableAndField[1]);
|
||||
|
||||
switch(kind)
|
||||
{
|
||||
case "label":
|
||||
{
|
||||
if(tableAndField.length == 2)
|
||||
{
|
||||
return (field == null ? "does not resolve to a table field" : null);
|
||||
}
|
||||
boolean found = table != null || qInstance.getProcess(target) != null || qInstance.getApp(target) != null;
|
||||
return (found ? null : "does not resolve to a table, process or app");
|
||||
}
|
||||
case "default":
|
||||
{
|
||||
return (field == null ? "does not resolve to a table field" : (field.getDefaultValue() == null ? "the field has no default value" : null));
|
||||
}
|
||||
case "maxLength":
|
||||
{
|
||||
return (field == null ? "does not resolve to a table field" : (field.getMaxLength() == null ? "the field has no maximum length" : null));
|
||||
}
|
||||
case "values":
|
||||
{
|
||||
QPossibleValueSource source = qInstance.getPossibleValueSource(target);
|
||||
return (source == null ? "does not resolve to a possible-value source" : (source.getType() == QPossibleValueSourceType.ENUM ? null : "is not an enum source"));
|
||||
}
|
||||
default:
|
||||
{
|
||||
return ("unsupported reference kind");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle, as parsed YAML.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> loadBundle() throws IOException
|
||||
{
|
||||
try(InputStream inputStream = HelpBundleCoverageTest.class.getClassLoader().getResourceAsStream(BUNDLE_RESOURCE))
|
||||
{
|
||||
assertThat(inputStream).as("classpath resource " + BUNDLE_RESOURCE).isNotNull();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> bundle = new ObjectMapper(new YAMLFactory()).readValue(inputStream, Map.class);
|
||||
return (bundle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Entry> loadEntries() throws IOException
|
||||
{
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
for(Map<String, Object> row : (List<Map<String, Object>>) loadBundle().get("entries"))
|
||||
{
|
||||
entries.add(new Entry(String.valueOf(row.get("key")), (String) row.get("summary"), (String) row.get("content")));
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail with every problem at once.
|
||||
*******************************************************************************/
|
||||
private static void assertNoProblems(List<String> problems)
|
||||
{
|
||||
assertThat(problems).as(problems.size() + " help bundle problem(s):\n" + String.join("\n", problems)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class MarketingQBitProducerTest extends BaseMarketingTest
|
||||
assertThat(qInstance.getQBits().values().iterator().next().getArtifactId()).isEqualTo("qbit-marketing");
|
||||
|
||||
Set<String> tables = qInstance.getTables().keySet();
|
||||
assertThat(tables).hasSize(18).allMatch(t -> t.matches("^marketing_[a-z][a-z0-9_]*$"));
|
||||
assertThat(tables).hasSize(19).allMatch(t -> t.matches("^marketing_[a-z][a-z0-9_]*$"));
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
assertThat(table.getPermissionRules().getPermissionBaseName()).startsWith("marketing");
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.marketing.metadata.MarketingMetaDataBuilder;
|
||||
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.HelpTextRenderer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every marketing_* table:
|
||||
** - runtime code (not just its model, metadata, home widgets or the starter
|
||||
** data) reads or writes it, so no table exists only on paper;
|
||||
** - it is either seeded by the starter data ("Load Verified Starter Data") or
|
||||
** listed here with why it starts empty;
|
||||
** - it has bundle help that says how its rows get created, naming the starter
|
||||
** data action where that is one of the ways.
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class MarketingTablesGuardrailTest extends BaseMarketingTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "marketing");
|
||||
|
||||
/*******************************************************************************
|
||||
** Written by MarketingStarterData (checked against its source below).
|
||||
*******************************************************************************/
|
||||
static final List<String> SEEDED_BY_STARTER_DATA = List.of("marketing_product", "marketing_evidence_source", "marketing_claim", "marketing_audience",
|
||||
"marketing_campaign", "marketing_metric", "marketing_channel", "marketing_policy");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("marketing_content", "user content: a person or the agent drafts it (starter data never writes publications)"),
|
||||
Map.entry("marketing_content_revision", "runtime: content is created or changed"),
|
||||
Map.entry("marketing_approval", "runtime: a person approves content"),
|
||||
Map.entry("marketing_execution", "runtime: Schedule on approved content"),
|
||||
Map.entry("marketing_execution_attempt", "runtime: an execution is tried"),
|
||||
Map.entry("marketing_test_delivery", "runtime: the test transport accepts an execution"),
|
||||
Map.entry("marketing_agent_run", "runtime: someone starts an agent action"),
|
||||
Map.entry("marketing_agent_step", "runtime: an agent run makes a call"),
|
||||
Map.entry("marketing_finding", "runtime: Check Readiness, the agent or a person"),
|
||||
Map.entry("marketing_outcome_event", "runtime: an importer, the API, Update Results from CRM or a person"),
|
||||
Map.entry("marketing_metric_daily", "runtime: rolled up from outcome events"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
Map<String, String> entityByTable = entityByTable();
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("seed")) && !p.startsWith(MAIN.resolve("widgets")))
|
||||
.map(MarketingTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : marketingTables())
|
||||
{
|
||||
String entity = entityByTable.get(table);
|
||||
assertThat(entity).as("entity class for " + table).isNotNull();
|
||||
Pattern use = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
|
||||
.as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
String starter = read(MAIN.resolve("seed").resolve("MarketingStarterData.java"));
|
||||
Map<String, String> entityByTable = entityByTable();
|
||||
for(String table : marketingTables())
|
||||
{
|
||||
boolean seeded = SEEDED_BY_STARTER_DATA.contains(table);
|
||||
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the starter data or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
|
||||
assertThat(starter.contains(entityByTable.get(table) + ".TABLE_NAME")).as(table + " seeded iff MarketingStarterData writes it").isEqualTo(seeded);
|
||||
}
|
||||
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> qInstance.getTable(table) != null, "STARTS_EMPTY names only real tables");
|
||||
assertThat(qInstance.getProcess(MarketingTableProcesses.LOAD_STARTER_DATA).getTableName()).isEqualTo("marketing_product");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have help that says how rows get created, and the
|
||||
** seeded tables name the starter-data action.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
String action = qInstance.getProcess(MarketingTableProcesses.LOAD_STARTER_DATA).getLabel();
|
||||
for(String table : marketingTables())
|
||||
{
|
||||
String content = help("table:" + table + ";slot:help");
|
||||
assertThat(content).as(table + " table help").isNotBlank();
|
||||
assertThat(content).as(table + " help says how rows get created").contains("How rows get created");
|
||||
assertThat(content).as(table + " help has no unresolved reference").doesNotContain("{{");
|
||||
if(SEEDED_BY_STARTER_DATA.contains(table))
|
||||
{
|
||||
assertThat(content).as(table + " help names the starter-data action").contains(action);
|
||||
}
|
||||
}
|
||||
assertThat(help("app:" + MarketingMetaDataBuilder.APP_NAME + ";slot:help")).isNotBlank();
|
||||
assertThat(help("table:marketing_content;slot:help")).as("enum references become the values").contains("Draft");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle is registered at the QBIT layer, so a host bundle for the same
|
||||
** key wins and the host's own help is kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostHelpIsNotOverridden()
|
||||
{
|
||||
HelpBundles.register(qInstance, "help/test-host-override.yaml");
|
||||
assertThat(help("table:marketing_claim;slot:help")).isEqualTo("host text");
|
||||
assertThat(help("table:marketing_product;slot:help")).contains("How rows get created");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The winning bundle text for a key (ALL_SCREENS role), rendered by core
|
||||
** against the instance the way the frontend sees it.
|
||||
*******************************************************************************/
|
||||
private String help(String key)
|
||||
{
|
||||
HelpBundles.WinningEntry winner = HelpBundles.of(qInstance).resolveWinningEntries().get(new HelpBundles.EntryKey(key, "ALL_SCREENS"));
|
||||
if(winner == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
HelpTextRenderer renderer = new HelpTextRenderer(qInstance);
|
||||
String summary = winner.entry().getSummary() == null ? "" : renderer.render(winner.entry().getSummary().strip());
|
||||
return ((summary + "\n\n" + renderer.render(winner.entry().getContent().strip())).strip());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> marketingTables()
|
||||
{
|
||||
List<String> tables = qInstance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("marketing_")).sorted().toList();
|
||||
assertThat(tables).hasSize(19);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** model/MarketingX.java declares TABLE_NAME = "marketing_x".
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> entityByTable() throws Exception
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\"");
|
||||
try(Stream<Path> files = Files.list(MAIN.resolve("model")))
|
||||
{
|
||||
for(Path file : files.filter(f -> f.toString().endsWith(".java")).toList())
|
||||
{
|
||||
Matcher m = p.matcher(read(file));
|
||||
if(m.find())
|
||||
{
|
||||
rs.put(m.group(1), file.getFileName().toString().replace(".java", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
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.marketing.measurement.ImportedOutcome;
|
||||
import com.kingsrook.qbits.marketing.measurement.MarketingOutcomeImporter;
|
||||
import com.kingsrook.qbits.marketing.measurement.MeasurementReport;
|
||||
import com.kingsrook.qbits.marketing.measurement.MeasurementRollupService;
|
||||
import com.kingsrook.qbits.marketing.measurement.OutcomeImportService;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingMetric;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingMetricDaily;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
|
||||
import com.kingsrook.qbits.marketing.model.enums.CountMode;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
|
||||
import com.kingsrook.qbits.marketing.model.enums.InstrumentationStatus;
|
||||
import com.kingsrook.qbits.marketing.processes.MarketingActions;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
|
||||
import com.kingsrook.qbits.marketing.widgets.MarketingWidgets;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
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.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The persisted daily rollup of outcome events (marketing_metric_daily): it
|
||||
** matches the raw computation across inserts, edits, deletes and test data;
|
||||
** Rebuild Results is idempotent; the campaign-wide unique count is not summed
|
||||
** from days; and the importer SPI stores outcomes through the normal dedupe.
|
||||
*******************************************************************************/
|
||||
class MeasurementRollupTest extends BaseMarketingTest
|
||||
{
|
||||
private static final Instant DAY_ONE = Instant.parse("2026-10-01T10:00:00Z");
|
||||
private static final Instant DAY_TWO = Instant.parse("2026-10-02T23:59:59Z");
|
||||
|
||||
private final FakeImporter importer = new FakeImporter("fake-analytics");
|
||||
|
||||
private Integer campaignId;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An importer that returns what the test queued, and remembers its "since".
|
||||
*******************************************************************************/
|
||||
static class FakeImporter implements MarketingOutcomeImporter
|
||||
{
|
||||
private final String name;
|
||||
final List<ImportedOutcome> outcomes = new ArrayList<>();
|
||||
final List<Instant> sinces = new ArrayList<>();
|
||||
boolean fail = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
FakeImporter(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<ImportedOutcome> fetch(QRecord campaign, Instant since) throws QException
|
||||
{
|
||||
sinces.add(since);
|
||||
if(fail)
|
||||
{
|
||||
throw (new QUserFacingException("the analytics export is unreachable"));
|
||||
}
|
||||
return (new ArrayList<>(outcomes));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeConfig(MarketingQBitConfig config)
|
||||
{
|
||||
config.withOutcomeImporter(importer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void beforeEach() throws QException
|
||||
{
|
||||
campaignId = campaign(product("p1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Integer event(FunnelStage stage, String type, String externalId, String actor, Integer quantity, Instant occurredAt, boolean test) throws QException
|
||||
{
|
||||
return (MarketingStore.insert(new MarketingOutcomeEvent().withCampaignId(campaignId).withStage(stage.getId()).withEventType(type).withSource("site")
|
||||
.withExternalEventId(externalId).withActorKey(actor).withQuantity(quantity).withOccurredAt(occurredAt).withIsTestData(test)).getValueInteger("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void metric(FunnelStage stage, String eventType, CountMode mode, InstrumentationStatus status) throws QException
|
||||
{
|
||||
MarketingStore.insert(new MarketingMetric().withCampaignId(campaignId).withStage(stage.getId()).withName(eventType).withEventType(eventType)
|
||||
.withCountMode(mode.getId()).withInstrumentationStatus(status.getId()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The raw computation, straight from the events: "day|stage|test|type" to
|
||||
** "raw|uniqueActors|withoutActor".
|
||||
*******************************************************************************/
|
||||
private Map<String, String> rawComputation() throws QException
|
||||
{
|
||||
Map<String, int[]> counts = new TreeMap<>();
|
||||
Map<String, Set<String>> actors = new TreeMap<>();
|
||||
for(QRecord event : MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "campaignId", campaignId))
|
||||
{
|
||||
LocalDate day = event.getValueInstant("occurredAt").atZone(ZoneOffset.UTC).toLocalDate();
|
||||
String key = day + "|" + event.getValueInteger("stage") + "|" + Boolean.TRUE.equals(event.getValueBoolean("isTestData")) + "|" + event.getValueString("eventType");
|
||||
int[] sum = counts.computeIfAbsent(key, k -> new int[2]);
|
||||
sum[0] += event.getValueInteger("quantity") == null ? 1 : event.getValueInteger("quantity");
|
||||
String actor = event.getValueString("actorKey");
|
||||
if(actor == null || actor.isBlank())
|
||||
{
|
||||
sum[1]++;
|
||||
}
|
||||
else
|
||||
{
|
||||
actors.computeIfAbsent(key, k -> new HashSet<>()).add(actor);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> expected = new TreeMap<>();
|
||||
counts.forEach((key, sum) -> expected.put(key, sum[0] + "|" + actors.getOrDefault(key, Set.of()).size() + "|" + sum[1]));
|
||||
return (expected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What the rollup table holds, in the same shape.
|
||||
*******************************************************************************/
|
||||
private Map<String, String> rollupRows() throws QException
|
||||
{
|
||||
Map<String, String> rows = new TreeMap<>();
|
||||
for(QRecord row : MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId))
|
||||
{
|
||||
rows.put(row.getValueLocalDate("dayUtc") + "|" + row.getValueInteger("funnelStage") + "|" + Boolean.TRUE.equals(row.getValueBoolean("isTestData")) + "|" + row.getValueString("eventType"),
|
||||
row.getValueInteger("rawCount") + "|" + row.getValueInteger("uniqueActors") + "|" + row.getValueInteger("eventsWithoutActor"));
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void assertRollupMatchesRaw() throws QException
|
||||
{
|
||||
assertThat(rollupRows()).isEqualTo(rawComputation());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rollup follows every insert, edit (quantity, actor, type, day, test
|
||||
** flag) and delete, including the last event of a day and test-data rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRollupMatchesRawAcrossInsertsEditsAndDeletes() throws QException
|
||||
{
|
||||
Integer v1 = event(FunnelStage.AWARENESS, "page_view", "v1", "x", null, DAY_ONE, false);
|
||||
Integer v2 = event(FunnelStage.AWARENESS, "page_view", "v2", "x", 3, DAY_ONE, false);
|
||||
Integer v3 = event(FunnelStage.AWARENESS, "page_view", "v3", null, null, DAY_ONE, false);
|
||||
Integer v4 = event(FunnelStage.AWARENESS, "page_view", "v4", "y", 2, DAY_TWO, false);
|
||||
Integer r1 = event(FunnelStage.ACTIVATION, "first_run", "r1", "x", null, DAY_ONE, false);
|
||||
Integer t1 = event(FunnelStage.AWARENESS, "page_view", "t1", "z", null, DAY_ONE, true);
|
||||
assertRollupMatchesRaw();
|
||||
assertThat(rollupRows()).containsEntry("2026-10-01|" + FunnelStage.AWARENESS.getId() + "|false|page_view", "5|1|1")
|
||||
.containsEntry("2026-10-01|" + FunnelStage.AWARENESS.getId() + "|true|page_view", "1|1|0")
|
||||
.containsEntry("2026-10-02|" + FunnelStage.AWARENESS.getId() + "|false|page_view", "2|1|0")
|
||||
.hasSize(4);
|
||||
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// edits: quantity, actor, type, day, test flag //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", v2).withValue("quantity", 10));
|
||||
assertRollupMatchesRaw();
|
||||
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", v3).withValue("actorKey", "w"));
|
||||
assertRollupMatchesRaw();
|
||||
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", r1).withValue("eventType", "second_run"));
|
||||
assertRollupMatchesRaw();
|
||||
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", v1).withValue("occurredAt", DAY_TWO));
|
||||
assertRollupMatchesRaw();
|
||||
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", t1).withValue("isTestData", false));
|
||||
assertRollupMatchesRaw();
|
||||
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", v4).withValue("isTestData", true));
|
||||
assertRollupMatchesRaw();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// an event cannot lose its time (the rollup is by day) //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
assertThatThrownBy(() -> MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", v2).withValue("occurredAt", null)))
|
||||
.hasMessageContaining("needs the time");
|
||||
|
||||
/////////////////////////////////////
|
||||
// deletes, down to nothing //
|
||||
/////////////////////////////////////
|
||||
MarketingStore.delete(MarketingOutcomeEvent.TABLE_NAME, List.of(v2));
|
||||
assertRollupMatchesRaw();
|
||||
MarketingStore.delete(MarketingOutcomeEvent.TABLE_NAME, List.of(v1, v3, v4, r1, t1));
|
||||
assertThat(rollupRows()).isEmpty();
|
||||
assertRollupMatchesRaw();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rollup rows are system-written: a direct write is refused, and each
|
||||
** row carries the product of its campaign (for the product record lock).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRollupIsSystemWrittenAndCarriesTheProduct() throws QException
|
||||
{
|
||||
event(FunnelStage.AWARENESS, "page_view", "v1", "x", null, DAY_ONE, false);
|
||||
QRecord row = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId).get(0);
|
||||
assertThat(row.getValueInteger("productId")).isEqualTo(MarketingStore.get("marketing_campaign", campaignId).getValueInteger("productId"));
|
||||
assertThat(row.getValueInstant("recomputedAt")).isNotNull();
|
||||
|
||||
assertThatThrownBy(() -> MarketingStore.insert(new MarketingMetricDaily().withCampaignId(campaignId).withDayUtc(LocalDate.of(2026, 10, 3))
|
||||
.withFunnelStage(FunnelStage.AWARENESS.getId()).withEventType("x").withIsTestData(false).withRawCount(99)))
|
||||
.hasMessageContaining("written only by Marketing");
|
||||
assertThatThrownBy(() -> MarketingStore.update(MarketingMetricDaily.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("rawCount", 99)))
|
||||
.hasMessageContaining("written only by Marketing");
|
||||
assertThatThrownBy(() -> MarketingStore.delete(MarketingMetricDaily.TABLE_NAME, List.of(row.getValue("id"))))
|
||||
.hasMessageContaining("written only by Marketing");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rebuild Results repairs a damaged rollup (wrong numbers, missing rows, a
|
||||
** stale row) from the raw events, and a second run changes nothing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRebuildIsIdempotent() throws QException
|
||||
{
|
||||
event(FunnelStage.AWARENESS, "page_view", "v1", "x", null, DAY_ONE, false);
|
||||
event(FunnelStage.AWARENESS, "page_view", "v2", "y", 4, DAY_TWO, false);
|
||||
event(FunnelStage.ACTIVATION, "first_run", "r1", "x", null, DAY_ONE, true);
|
||||
Map<String, String> expected = rawComputation();
|
||||
assertThat(rollupRows()).isEqualTo(expected);
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// damage: a wrong number, a missing row, a stale row //
|
||||
///////////////////////////////////////////////////////////
|
||||
List<QRecord> rows = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId);
|
||||
MarketingStore.updateInternal(MarketingMetricDaily.TABLE_NAME, new QRecord().withValue("id", rows.get(0).getValue("id")).withValue("rawCount", 999));
|
||||
MarketingStore.deleteInternal(MarketingMetricDaily.TABLE_NAME, List.<Serializable>of(rows.get(1).getValue("id")));
|
||||
MarketingStore.insertInternal(new MarketingMetricDaily().withCampaignId(campaignId).withDayUtc(LocalDate.of(2026, 9, 1)).withFunnelStage(FunnelStage.AWARENESS.getId())
|
||||
.withEventType("ghost").withIsTestData(false).withRawCount(7).withUniqueActors(7).withEventsWithoutActor(0));
|
||||
assertThat(rollupRows()).isNotEqualTo(expected);
|
||||
|
||||
String first = new MeasurementRollupService().rebuild(campaignId);
|
||||
assertThat(first).isEqualTo("rebuilt 3 daily result row(s) over 3 day(s)");
|
||||
assertThat(rollupRows()).isEqualTo(expected);
|
||||
|
||||
List<QRecord> afterFirst = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId);
|
||||
String second = new MeasurementRollupService().rebuild(campaignId);
|
||||
List<QRecord> afterSecond = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId);
|
||||
assertThat(second).isEqualTo("rebuilt 3 daily result row(s) over 2 day(s)");
|
||||
assertThat(rollupRows()).isEqualTo(expected);
|
||||
assertThat(afterSecond).hasSameSizeAs(afterFirst);
|
||||
for(int i = 0; i < afterFirst.size(); i++)
|
||||
{
|
||||
assertThat(afterSecond.get(i).getValue("id")).isEqualTo(afterFirst.get(i).getValue("id"));
|
||||
assertThat(afterSecond.get(i).getValueInstant("recomputedAt")).as("an unchanged row is not rewritten").isEqualTo(afterFirst.get(i).getValueInstant("recomputedAt"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rebuild Results is a campaign record action with standard summary lines,
|
||||
** and needs the same permission as Update Results from CRM.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testRebuildResultsAction() throws QException
|
||||
{
|
||||
event(FunnelStage.AWARENESS, "page_view", "v1", "x", null, DAY_ONE, false);
|
||||
MarketingStore.deleteInternal(MarketingMetricDaily.TABLE_NAME, List.<Serializable>of(MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId).get(0).getValue("id")));
|
||||
assertThat(rollupRows()).isEmpty();
|
||||
|
||||
RunProcessOutput output = new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingActions.REBUILD_RESULTS)
|
||||
.withCallback(QProcessCallbackFactory.forPrimaryKey("id", campaignId)).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
|
||||
List<ProcessSummaryLine> lines = (List<ProcessSummaryLine>) (List<?>) output.getValues().get("processResults");
|
||||
assertThat(lines).anySatisfy(l -> assertThat(l.getMessage()).isEqualTo("rebuilt 1 daily result row(s) over 1 day(s)"));
|
||||
assertRollupMatchesRaw();
|
||||
|
||||
as(user("reader", MarketingPermissions.APP_ACCESS, MarketingPermissions.READ));
|
||||
assertThatThrownBy(() -> new MeasurementRollupService().rebuild(campaignId)).hasMessageContaining(MarketingPermissions.PREPARE);
|
||||
as(admin());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The report reads the rollup and a backend COUNT_DISTINCT: an actor seen
|
||||
** on two days is one unique actor for the campaign (not the sum of days),
|
||||
** and test data and not-measured stay as they were.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReportUsesTheRollupAndCountsUniqueActorsAcrossDays() throws QException
|
||||
{
|
||||
metric(FunnelStage.AWARENESS, "page_view", CountMode.UNIQUE_ACTORS, InstrumentationStatus.PARTIAL);
|
||||
metric(FunnelStage.ACTIVATION, "first_run", CountMode.RAW_EVENTS, InstrumentationStatus.INSTRUMENTED);
|
||||
metric(FunnelStage.REPEAT_USE, "second_run", CountMode.UNIQUE_ACTORS, InstrumentationStatus.NOT_INSTRUMENTED);
|
||||
|
||||
event(FunnelStage.AWARENESS, "page_view", "v1", "x", null, DAY_ONE, false);
|
||||
event(FunnelStage.AWARENESS, "page_view", "v2", "x", null, DAY_TWO, false);
|
||||
event(FunnelStage.AWARENESS, "page_view", "v3", "y", 2, DAY_TWO, false);
|
||||
event(FunnelStage.AWARENESS, "page_view", "v4", null, null, DAY_ONE, false);
|
||||
event(FunnelStage.AWARENESS, "page_view", "v5", " ", null, DAY_ONE, false);
|
||||
event(FunnelStage.AWARENESS, "page_view", "tv", "tester", null, DAY_ONE, true);
|
||||
event(FunnelStage.ACTIVATION, "first_run", "r1", "x", 5, DAY_ONE, false);
|
||||
event(FunnelStage.AWARENESS, "star", "s1", "y", null, DAY_ONE, false);
|
||||
|
||||
MeasurementReport report = MeasurementReport.forCampaign(campaignId);
|
||||
MeasurementReport.Row views = report.getRows().stream().filter(r -> r.eventType().equals("page_view")).findFirst().orElseThrow();
|
||||
MeasurementReport.Row runs = report.getRows().stream().filter(r -> r.eventType().equals("first_run")).findFirst().orElseThrow();
|
||||
MeasurementReport.Row again = report.getRows().stream().filter(r -> r.eventType().equals("second_run")).findFirst().orElseThrow();
|
||||
|
||||
assertThat(views.rawEvents()).isEqualTo(6);
|
||||
assertThat(views.uniqueActors()).as("x on two days is one actor").isEqualTo(2);
|
||||
assertThat(views.eventsWithoutActor()).isEqualTo(2);
|
||||
assertThat(views.headline()).isEqualTo("2 unique actors");
|
||||
assertThat(views.note()).contains("lower bound").contains("2 event(s) have no actor");
|
||||
assertThat(runs.rawEvents()).isEqualTo(5);
|
||||
assertThat(runs.headline()).isEqualTo("5 events");
|
||||
assertThat(again.headline()).isEqualTo(MeasurementReport.NOT_MEASURED);
|
||||
assertThat(report.getTestEvents()).isEqualTo(1);
|
||||
assertThat(report.getLimitations()).anyMatch(l -> l.contains("1 test event(s)")).anyMatch(l -> l.contains("AWARENESS/star")).anyMatch(l -> l.contains("lower bound"));
|
||||
assertThat(report.toMap()).containsEntry("realEventCount", 7).containsEntry("testEventCountExcluded", 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An importer's outcomes go through the normal insert path: a repeat is
|
||||
** dropped by (source, external id), the importer is told what it already
|
||||
** delivered, and the daily rollup follows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testImporterRepeatIsDeduplicated() throws QException
|
||||
{
|
||||
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-1", "x", DAY_ONE, 4, false, null));
|
||||
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-2", "y", DAY_TWO, null, false, "second day"));
|
||||
importer.outcomes.add(new ImportedOutcome(FunnelStage.ENGAGEMENT, "signup", "ga-3", "x", DAY_TWO, 1, true, null));
|
||||
importer.outcomes.add(new ImportedOutcome(null, "bad", "ga-4", null, null, null, null, null));
|
||||
|
||||
OutcomeImportService service = new OutcomeImportService();
|
||||
assertThat(service.blocker()).isNull();
|
||||
assertThat(service.importOutcomes(campaignId)).isEqualTo("fake-analytics: 3 imported, 0 already recorded, 1 invalid (skipped)");
|
||||
assertThat(service.importOutcomes(campaignId)).isEqualTo("fake-analytics: 0 imported, 3 already recorded, 1 invalid (skipped)");
|
||||
|
||||
List<QRecord> events = MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "campaignId", campaignId);
|
||||
assertThat(events).hasSize(3).allSatisfy(e -> assertThat(e.getValueString("source")).isEqualTo("fake-analytics"));
|
||||
assertThat(events).anySatisfy(e -> assertThat(e.getValueInteger("quantity")).isEqualTo(4));
|
||||
assertThat(importer.sinces).containsExactly(null, DAY_TWO);
|
||||
assertRollupMatchesRaw();
|
||||
assertThat(rollupRows()).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Import Outcomes is a campaign record action with standard lines; one
|
||||
** failing importer is named and does not stop the others.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testImportOutcomesActionAndFailingImporter() throws QException
|
||||
{
|
||||
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-1", "x", DAY_ONE, null, false, null));
|
||||
|
||||
RunProcessOutput output = new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingActions.IMPORT_OUTCOMES)
|
||||
.withCallback(QProcessCallbackFactory.forPrimaryKey("id", campaignId)).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
|
||||
List<ProcessSummaryLine> lines = (List<ProcessSummaryLine>) (List<?>) output.getValues().get("processResults");
|
||||
assertThat(lines).anySatisfy(l -> assertThat(l.getMessage()).isEqualTo("fake-analytics: 1 imported, 0 already recorded"));
|
||||
|
||||
importer.fail = true;
|
||||
output = new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingActions.IMPORT_OUTCOMES)
|
||||
.withCallback(QProcessCallbackFactory.forPrimaryKey("id", campaignId)).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
|
||||
lines = (List<ProcessSummaryLine>) (List<?>) output.getValues().get("processResults");
|
||||
assertThat(lines).anySatisfy(l -> assertThat(l.getMessage()).contains("not done: fake-analytics: failed (the analytics export is unreachable)"));
|
||||
assertThat(MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "campaignId", campaignId)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registered importers are listed by the Configuration Status widget (and
|
||||
** its absence says so); the names Marketing uses itself are reserved.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConfigurationStatusListsImportersAndNamesAreChecked() throws QException
|
||||
{
|
||||
TableData status = (TableData) new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(qInstance.getWidget(MarketingWidgets.CONFIGURATION))).getWidgetData();
|
||||
assertThat(status.getRows()).anySatisfy(r -> assertThat(String.valueOf(r.get("integration"))).contains("Outcome importer").contains("fake-analytics"));
|
||||
|
||||
List<String> errors = new ArrayList<>();
|
||||
new MarketingQBitConfig().withBackendName(BACKEND).withOutcomeImporter(new FakeImporter("crm")).withOutcomeImporter(new FakeImporter("dup"))
|
||||
.withOutcomeImporter(new FakeImporter("dup")).validate(qInstance, errors);
|
||||
assertThat(errors).anyMatch(e -> e.contains("[crm] is reserved")).anyMatch(e -> e.contains("duplicate outcome importer name [dup]"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,19 +25,34 @@ import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.Statement;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.marketing.approval.ApprovalService;
|
||||
import com.kingsrook.qbits.marketing.measurement.ImportedOutcome;
|
||||
import com.kingsrook.qbits.marketing.measurement.MeasurementReport;
|
||||
import com.kingsrook.qbits.marketing.measurement.MeasurementRollupService;
|
||||
import com.kingsrook.qbits.marketing.measurement.OutcomeImportService;
|
||||
import com.kingsrook.qbits.marketing.execution.ExecutionService;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingAgentRun;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingApproval;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingContent;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingContentRevision;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingExecution;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingMetric;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingMetricDaily;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingProduct;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingTestDelivery;
|
||||
import com.kingsrook.qbits.marketing.model.enums.AgentRunStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.AgentTask;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ApprovalStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.CampaignGoal;
|
||||
import com.kingsrook.qbits.marketing.model.enums.CampaignStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.CountMode;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ExecutionStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
|
||||
import com.kingsrook.qbits.marketing.model.enums.InstrumentationStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ProductMaturity;
|
||||
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
|
||||
import com.kingsrook.qbits.marketing.seed.MarketingStarterData;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
@@ -91,13 +106,23 @@ class PersistenceRestartTest
|
||||
** A fresh "process": a new instance and runtime over the database files.
|
||||
*******************************************************************************/
|
||||
private QInstance boot() throws Exception
|
||||
{
|
||||
return (boot(new MarketingQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh "process" with the given config (its backend is set here).
|
||||
*******************************************************************************/
|
||||
private QInstance boot(MarketingQBitConfig config) throws Exception
|
||||
{
|
||||
QInstance qInstance = new QInstance();
|
||||
qInstance.addBackend(new RDBMSBackendMetaData().withName("rdbms").withVendor("h2").withHostName("file").withDatabaseName(directory.resolve("marketing").toString())
|
||||
.withUsername("sa").withPassword(""));
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
qInstance.setEnforcePermissionsInCoreActions(true);
|
||||
new MarketingQBitProducer().withQBitConfig(new MarketingQBitConfig().withBackendName("rdbms")).produce(qInstance);
|
||||
new MarketingQBitProducer().withQBitConfig(config.withBackendName("rdbms")).produce(qInstance);
|
||||
new QInstanceValidator().validate(qInstance);
|
||||
QContext.init(qInstance, BaseMarketingTest.admin());
|
||||
return (qInstance);
|
||||
@@ -116,10 +141,9 @@ class PersistenceRestartTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** A new database directory with the shipped H2 DDL applied.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSurvivesRestart() throws Exception
|
||||
private void createSchema() throws Exception
|
||||
{
|
||||
directory = Files.createDirectories(Path.of("target", "h2-restart", String.valueOf(System.nanoTime())).toAbsolutePath());
|
||||
try(Connection connection = DriverManager.getConnection(jdbcUrl(), "sa", ""); Statement statement = connection.createStatement();
|
||||
@@ -135,6 +159,30 @@ class PersistenceRestartTest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Close the database files (the restart).
|
||||
*******************************************************************************/
|
||||
private void shutdown() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(jdbcUrl(), "sa", ""); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("SHUTDOWN");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSurvivesRestart() throws Exception
|
||||
{
|
||||
createSchema();
|
||||
|
||||
///////////////////////////////
|
||||
// 1. before the restart //
|
||||
@@ -199,4 +247,71 @@ class PersistenceRestartTest
|
||||
assertThat(MarketingStore.get(MarketingAgentRun.TABLE_NAME, runId).getValueInteger("status")).isEqualTo(AgentRunStatus.FAILED.getId());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The daily rollup on the real RDBMS backend: it follows inserts, edits and
|
||||
** deletes in SQL, the report's COUNT_DISTINCT and SUMs run in the database,
|
||||
** and the rows survive a restart (and Rebuild Results changes nothing).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRollupAndImporterOverRdbmsSurviveRestart() throws Exception
|
||||
{
|
||||
createSchema();
|
||||
MeasurementRollupTest.FakeImporter importer = new MeasurementRollupTest.FakeImporter("fake-analytics");
|
||||
Instant dayOne = Instant.parse("2026-10-01T10:00:00Z");
|
||||
Instant dayTwo = Instant.parse("2026-10-02T23:59:59Z");
|
||||
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-1", "x", dayOne, 4, false, null));
|
||||
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-2", "x", dayTwo, null, false, null));
|
||||
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-3", "y", dayTwo, null, false, null));
|
||||
importer.outcomes.add(new ImportedOutcome(FunnelStage.AWARENESS, "page_view", "ga-4", null, dayTwo, null, true, null));
|
||||
|
||||
boot(new MarketingQBitConfig().withOutcomeImporter(importer));
|
||||
Integer productId = MarketingStore.insert(new MarketingProduct().withName("P").withSlug("p").withMaturity(ProductMaturity.RELEASED.getId())).getValueInteger("id");
|
||||
Integer campaignId = MarketingStore.insert(new MarketingCampaign().withProductId(productId).withName("C").withGoal(CampaignGoal.AWARENESS.getId())
|
||||
.withStatus(CampaignStatus.ACTIVE.getId())).getValueInteger("id");
|
||||
MarketingStore.insert(new MarketingMetric().withCampaignId(campaignId).withStage(FunnelStage.AWARENESS.getId()).withName("Views").withEventType("page_view")
|
||||
.withCountMode(CountMode.UNIQUE_ACTORS.getId()).withInstrumentationStatus(InstrumentationStatus.INSTRUMENTED.getId()));
|
||||
|
||||
OutcomeImportService importService = new OutcomeImportService();
|
||||
assertThat(importService.importOutcomes(campaignId)).isEqualTo("fake-analytics: 4 imported, 0 already recorded");
|
||||
assertThat(importService.importOutcomes(campaignId)).isEqualTo("fake-analytics: 0 imported, 4 already recorded");
|
||||
|
||||
MeasurementReport.Row views = MeasurementReport.forCampaign(campaignId).getRows().get(0);
|
||||
assertThat(views.rawEvents()).isEqualTo(6);
|
||||
assertThat(views.uniqueActors()).as("x on two days and y: distinct in the database").isEqualTo(2);
|
||||
assertThat(views.eventsWithoutActor()).isZero();
|
||||
List<QRecord> before = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId);
|
||||
assertThat(before).hasSize(3);
|
||||
QContext.clear();
|
||||
|
||||
shutdown();
|
||||
|
||||
/////////////////////////////////////
|
||||
// after the restart: same rows //
|
||||
/////////////////////////////////////
|
||||
boot(new MarketingQBitConfig().withOutcomeImporter(importer));
|
||||
List<QRecord> after = MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId);
|
||||
assertThat(after).hasSize(3);
|
||||
assertThat(after).extracting(r -> r.getValueLocalDate("dayUtc") + "|" + r.getValueBoolean("isTestData") + "|" + r.getValueInteger("rawCount") + "|" + r.getValueInteger("uniqueActors")
|
||||
+ "|" + r.getValueInteger("eventsWithoutActor")).containsExactlyInAnyOrder("2026-10-01|false|4|1|0", "2026-10-02|false|2|2|0", "2026-10-02|true|1|0|1");
|
||||
assertThat(after).allSatisfy(r -> assertThat(r.getValueInteger("productId")).isEqualTo(productId));
|
||||
|
||||
String rebuilt = new MeasurementRollupService().rebuild(campaignId);
|
||||
assertThat(rebuilt).isEqualTo("rebuilt 3 daily result row(s) over 2 day(s)");
|
||||
assertThat(MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId)).extracting(r -> r.getValue("id"))
|
||||
.containsExactlyInAnyOrderElementsOf(after.stream().map(r -> r.getValue("id")).toList());
|
||||
|
||||
/////////////////////////////////////////////////////////////
|
||||
// an edit and a delete are followed in SQL //
|
||||
/////////////////////////////////////////////////////////////
|
||||
Integer ga2 = MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "externalEventId", "ga-2").get(0).getValueInteger("id");
|
||||
Integer ga3 = MarketingStore.queryBy(MarketingOutcomeEvent.TABLE_NAME, "externalEventId", "ga-3").get(0).getValueInteger("id");
|
||||
MarketingStore.update(MarketingOutcomeEvent.TABLE_NAME, new QRecord().withValue("id", ga2).withValue("occurredAt", dayOne));
|
||||
MarketingStore.delete(MarketingOutcomeEvent.TABLE_NAME, List.of(ga3));
|
||||
assertThat(MarketingStore.queryBy(MarketingMetricDaily.TABLE_NAME, "campaignId", campaignId)).extracting(r -> r.getValueLocalDate("dayUtc") + "|" + r.getValueBoolean("isTestData")
|
||||
+ "|" + r.getValueInteger("rawCount") + "|" + r.getValueInteger("uniqueActors")).containsExactlyInAnyOrder("2026-10-01|false|5|1", "2026-10-02|true|1|0");
|
||||
MeasurementReport.Row afterViews = MeasurementReport.forCampaign(campaignId).getRows().get(0);
|
||||
assertThat(afterViews.rawEvents()).isEqualTo(5);
|
||||
assertThat(afterViews.uniqueActors()).isEqualTo(1);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway.CrmFormSubmissionRef;
|
||||
import com.kingsrook.qbits.marketing.crm.MarketingCrmGateway.CrmRecipient;
|
||||
import com.kingsrook.qbits.marketing.crm.QbitCrmGateway;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QbitCrmGateway against fixture tables shaped like qbit-crm 0.2.0's crm_*
|
||||
** tables.
|
||||
*******************************************************************************/
|
||||
class QbitCrmGatewayTest extends BaseMarketingTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotAvailableWithoutCrmTables()
|
||||
{
|
||||
assertThat(new QbitCrmGateway().isAvailable()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCrmFlows() throws QException
|
||||
{
|
||||
defineSnakeCaseTables();
|
||||
assertThat(QbitCrmGateway.CONTACT_TABLE).isEqualTo("crm_contact");
|
||||
exerciseGateway();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Eligibility, recipient scopes, form submissions and activities.
|
||||
*******************************************************************************/
|
||||
private void exerciseGateway() throws QException
|
||||
{
|
||||
QbitCrmGateway gateway = new QbitCrmGateway();
|
||||
assertThat(gateway.isAvailable()).isTrue();
|
||||
|
||||
Integer granted = insert(QbitCrmGateway.CONTACT_TABLE, new QRecord().withValue("email", "yes@example.org").withValue("doNotEmail", false));
|
||||
Integer noConsent = insert(QbitCrmGateway.CONTACT_TABLE, new QRecord().withValue("email", "none@example.org").withValue("doNotEmail", false));
|
||||
Integer suppressed = insert(QbitCrmGateway.CONTACT_TABLE, new QRecord().withValue("email", "dne@example.org").withValue("doNotEmail", true));
|
||||
for(Integer contactId : List.of(granted, suppressed))
|
||||
{
|
||||
insert(QbitCrmGateway.CONSENT_TABLE, new QRecord().withValue("contactId", contactId).withValue("consentType", 1).withValue("status", 1)
|
||||
.withValue("consentDate", Instant.now().minus(2, ChronoUnit.DAYS)));
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// checkEmailEligibility //
|
||||
/////////////////////////////
|
||||
assertThat(gateway.checkEmailEligibility(granted).eligible()).isTrue();
|
||||
assertThat(gateway.checkEmailEligibility(noConsent).reason()).isEqualTo("no_email_consent");
|
||||
assertThat(gateway.checkEmailEligibility(suppressed).reason()).isEqualTo("do_not_email");
|
||||
assertThat(gateway.checkEmailEligibility(9999).reason()).isEqualTo("contact_not_found");
|
||||
|
||||
///////////////////////////////////////
|
||||
// resolveRecipients: contact scope //
|
||||
///////////////////////////////////////
|
||||
List<CrmRecipient> single = gateway.resolveRecipients("crm-contact:" + granted);
|
||||
assertThat(single).hasSize(1);
|
||||
assertThat(single.get(0).address()).isEqualTo("yes@example.org");
|
||||
|
||||
/////////////////////////////////////
|
||||
// resolveRecipients: list scope //
|
||||
/////////////////////////////////////
|
||||
insert(QbitCrmGateway.LIST_MEMBER_TABLE, new QRecord().withValue("emailListId", 7).withValue("contactId", granted).withValue("status", 1));
|
||||
insert(QbitCrmGateway.LIST_MEMBER_TABLE, new QRecord().withValue("emailListId", 7).withValue("contactId", noConsent).withValue("status", 1));
|
||||
insert(QbitCrmGateway.LIST_MEMBER_TABLE, new QRecord().withValue("emailListId", 7).withValue("contactId", suppressed).withValue("status", 2));
|
||||
List<CrmRecipient> members = gateway.resolveRecipients("crm-list:7");
|
||||
assertThat(members).hasSize(3);
|
||||
assertThat(members.stream().filter(r -> r.eligibility().eligible()).map(CrmRecipient::address)).containsExactly("yes@example.org");
|
||||
assertThat(members.stream().map(r -> r.eligibility().reason())).contains("no_email_consent", "list_member_not_subscribed");
|
||||
|
||||
///////////////////////////
|
||||
// listFormSubmissions //
|
||||
///////////////////////////
|
||||
Instant created = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||
insert(QbitCrmGateway.FORM_SUBMISSION_TABLE, new QRecord().withValue("contactId", granted).withValue("utmCampaign", "spring").withValue("createDate", created));
|
||||
insert(QbitCrmGateway.FORM_SUBMISSION_TABLE, new QRecord().withValue("contactId", noConsent).withValue("utmCampaign", "other").withValue("createDate", created));
|
||||
List<CrmFormSubmissionRef> submissions = gateway.listFormSubmissions("spring", null);
|
||||
assertThat(submissions).hasSize(1);
|
||||
assertThat(submissions.get(0).contactId()).isEqualTo(granted);
|
||||
assertThat(gateway.listFormSubmissions("spring", Instant.now())).isEmpty();
|
||||
|
||||
//////////////////////
|
||||
// recordActivity //
|
||||
//////////////////////
|
||||
assertThatThrownBy(() -> gateway.recordActivity(granted, "Subject", "Body")).hasMessageContaining("Note");
|
||||
insert(QbitCrmGateway.ACTIVITY_TYPE_TABLE, new QRecord().withValue("name", "Note"));
|
||||
gateway.recordActivity(granted, "Subject", "Body");
|
||||
List<QRecord> activities = MarketingStore.query(QbitCrmGateway.ACTIVITY_TABLE, new QQueryFilter());
|
||||
assertThat(activities).hasSize(1);
|
||||
assertThat(activities.get(0).getValueString("subject")).isEqualTo("Subject");
|
||||
assertThat(activities.get(0).getValueInteger("contactId")).isEqualTo(granted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Integer insert(String table, QRecord record) throws QException
|
||||
{
|
||||
return (MarketingStore.insert(table, record).getValueInteger("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void defineSnakeCaseTables()
|
||||
{
|
||||
defineCrmTables(QbitCrmGateway.CONTACT_TABLE, QbitCrmGateway.CONSENT_TABLE, QbitCrmGateway.LIST_MEMBER_TABLE,
|
||||
QbitCrmGateway.FORM_SUBMISSION_TABLE, QbitCrmGateway.ACTIVITY_TABLE, QbitCrmGateway.ACTIVITY_TYPE_TABLE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Define fixture tables (memory backend) shaped like qbit-crm's.
|
||||
*******************************************************************************/
|
||||
private void defineCrmTables(String contact, String consent, String listMember, String formSubmission, String activity, String activityType)
|
||||
{
|
||||
defineTable(contact, field("email", QFieldType.STRING), field("doNotEmail", QFieldType.BOOLEAN), field("unsubscribeDate", QFieldType.DATE_TIME));
|
||||
defineTable(consent, field("contactId", QFieldType.INTEGER), field("consentType", QFieldType.INTEGER), field("status", QFieldType.INTEGER),
|
||||
field("consentDate", QFieldType.DATE_TIME), field("expiryDate", QFieldType.DATE_TIME));
|
||||
defineTable(listMember, field("emailListId", QFieldType.INTEGER), field("contactId", QFieldType.INTEGER), field("status", QFieldType.INTEGER));
|
||||
defineTable(formSubmission, field("contactId", QFieldType.INTEGER), field("utmCampaign", QFieldType.STRING), field("createDate", QFieldType.DATE_TIME));
|
||||
defineTable(activity, field("activityTypeId", QFieldType.INTEGER), field("subject", QFieldType.STRING), field("description", QFieldType.STRING),
|
||||
field("contactId", QFieldType.INTEGER), field("ownerUserId", QFieldType.STRING), field("isCompleted", QFieldType.BOOLEAN), field("completedDate", QFieldType.DATE_TIME));
|
||||
defineTable(activityType, field("name", QFieldType.STRING));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData field(String name, QFieldType type)
|
||||
{
|
||||
return (new QFieldMetaData(name, type));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void defineTable(String name, QFieldMetaData... fields)
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData().withName(name).withBackendName(BACKEND).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER));
|
||||
for(QFieldMetaData field : fields)
|
||||
{
|
||||
table.withField(field);
|
||||
}
|
||||
qInstance.addTable(table);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
/*******************************************************************************
|
||||
** Synchronous scripted responses and request snapshots for marketing tests.
|
||||
*******************************************************************************/
|
||||
class ScriptedModelProvider implements AssistantModelProvider
|
||||
public class ScriptedModelProvider implements AssistantModelProvider
|
||||
{
|
||||
private final String name;
|
||||
private final Deque<Step> steps = new ArrayDeque<>();
|
||||
@@ -51,7 +51,7 @@ class ScriptedModelProvider implements AssistantModelProvider
|
||||
/*******************************************************************************
|
||||
** Register under the name selected by the test configuration.
|
||||
*******************************************************************************/
|
||||
ScriptedModelProvider(String name)
|
||||
public ScriptedModelProvider(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ class ScriptedModelProvider implements AssistantModelProvider
|
||||
/*******************************************************************************
|
||||
** Queue a complete answer.
|
||||
*******************************************************************************/
|
||||
ScriptedModelProvider thenText(String text)
|
||||
public ScriptedModelProvider thenText(String text)
|
||||
{
|
||||
return (thenRespond(request -> new ModelResponse().withParts(List.of(ModelPart.text(text))).withStopReason(ModelResponse.STOP_END_TURN)));
|
||||
}
|
||||
@@ -71,7 +71,7 @@ class ScriptedModelProvider implements AssistantModelProvider
|
||||
/*******************************************************************************
|
||||
** Queue a single tool call.
|
||||
*******************************************************************************/
|
||||
ScriptedModelProvider thenToolUse(String toolName, Map<String, Object> input)
|
||||
public ScriptedModelProvider thenToolUse(String toolName, Map<String, Object> input)
|
||||
{
|
||||
return (thenToolUses(null, List.<Object[]>of(new Object[] { toolName, input })));
|
||||
}
|
||||
@@ -82,7 +82,7 @@ class ScriptedModelProvider implements AssistantModelProvider
|
||||
** Queue parallel tool calls using the test fixtures' name/input pairs.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
ScriptedModelProvider thenToolUses(String preamble, List<Object[]> calls)
|
||||
public ScriptedModelProvider thenToolUses(String preamble, List<Object[]> calls)
|
||||
{
|
||||
return (thenRespond(request ->
|
||||
{
|
||||
@@ -104,7 +104,7 @@ class ScriptedModelProvider implements AssistantModelProvider
|
||||
/*******************************************************************************
|
||||
** Queue a provider failure, including usage already incurred.
|
||||
*******************************************************************************/
|
||||
ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message)
|
||||
public ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message)
|
||||
{
|
||||
return (thenRespond(request ->
|
||||
{
|
||||
@@ -117,7 +117,7 @@ class ScriptedModelProvider implements AssistantModelProvider
|
||||
/*******************************************************************************
|
||||
** Queue a response that can inspect the request or change test state.
|
||||
*******************************************************************************/
|
||||
ScriptedModelProvider thenRespond(Step step)
|
||||
public ScriptedModelProvider thenRespond(Step step)
|
||||
{
|
||||
steps.add(step);
|
||||
return (this);
|
||||
@@ -128,7 +128,7 @@ class ScriptedModelProvider implements AssistantModelProvider
|
||||
/*******************************************************************************
|
||||
** Set usage for budget and cost assertions.
|
||||
*******************************************************************************/
|
||||
ScriptedModelProvider withUsagePerCall(ModelUsage usage)
|
||||
public ScriptedModelProvider withUsagePerCall(ModelUsage usage)
|
||||
{
|
||||
usagePerCall = usage;
|
||||
return (this);
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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.marketing.demo;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelUsage;
|
||||
import com.kingsrook.qbits.marketing.MarketingQBitConfig;
|
||||
import com.kingsrook.qbits.marketing.MarketingQBitProducer;
|
||||
import com.kingsrook.qbits.marketing.MarketingStore;
|
||||
import com.kingsrook.qbits.marketing.ScriptedModelProvider;
|
||||
import com.kingsrook.qbits.marketing.agent.MarketingAgentService;
|
||||
import com.kingsrook.qbits.marketing.agent.tools.MarketingTools;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingCampaign;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingChannel;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingClaim;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingContent;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent;
|
||||
import com.kingsrook.qbits.marketing.model.MarketingProduct;
|
||||
import com.kingsrook.qbits.marketing.model.enums.AgentTask;
|
||||
import com.kingsrook.qbits.marketing.model.enums.ClaimStatus;
|
||||
import com.kingsrook.qbits.marketing.model.enums.FunnelStage;
|
||||
import com.kingsrook.qbits.marketing.processes.MarketingActions;
|
||||
import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses;
|
||||
import com.kingsrook.qbits.marketing.security.MarketingPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A local demo of the Marketing qbit for screenshots and manual checks (not a
|
||||
** test, not shipped): an in-memory H2 database created from the shipped DDL
|
||||
** (src/test/resources/marketing-h2.sql), a mock login as a user holding every
|
||||
** marketing permission, the verified starter data, and some history made
|
||||
** through the real services: a readiness check, an agent draft (offline,
|
||||
** scripted model), review, approval, scheduling, a controlled test execution
|
||||
** with its attempt and test delivery, and a few outcome events.
|
||||
**
|
||||
** Run it (from the repository root), then the Next UI dev server against it:
|
||||
**
|
||||
** ./mvnw -q test-compile
|
||||
** ./mvnw -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=test
|
||||
** java -cp "target/test-classes;target/classes;$(cat target/cp.txt)" com.kingsrook.qbits.marketing.demo.MarketingDemoServer
|
||||
**
|
||||
** (Use ':' instead of ';' in the classpath on Linux/macOS. The server listens
|
||||
** on port 8000, or MARKETING_DEMO_PORT.)
|
||||
**
|
||||
** QQQ_BACKEND_URL=http://localhost:8000 pnpm dev (in qqq-frontend-next; port 3000)
|
||||
*******************************************************************************/
|
||||
public class MarketingDemoServer extends AbstractQQQApplication
|
||||
{
|
||||
static final String BACKEND = "demo";
|
||||
static final String USER = "dana";
|
||||
|
||||
private static final String BODY = "QQQ 4.0 is on Maven Central: you can try it with the quickstart in a few minutes. Java 21, Docker and Bash are required.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start the demo.
|
||||
*******************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
MarketingDemoServer application = new MarketingDemoServer();
|
||||
new QApplicationJavalinServer(application)
|
||||
.withServeFrontendNext(false)
|
||||
.withServeFrontendMaterialDashboard(false)
|
||||
.withPort(Integer.parseInt(System.getenv().getOrDefault("MARKETING_DEMO_PORT", "8000")))
|
||||
.start();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The demo instance (seeded on definition).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
QInstance qInstance = new QInstance();
|
||||
RDBMSBackendMetaData backend = new RDBMSBackendMetaData().withName(BACKEND).withVendor("h2").withHostName("mem").withDatabaseName("marketing_demo").withUsername("sa");
|
||||
backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1");
|
||||
qInstance.addBackend(backend);
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(DemoSessionCustomizer.class)));
|
||||
qInstance.setBranding(new QBrandingMetaData().withAppName("Marketing demo").withCompanyName("QRun-IO"));
|
||||
qInstance.setEnforcePermissionsInCoreActions(true);
|
||||
|
||||
createTables(backend);
|
||||
|
||||
ScriptedModelProvider model = new ScriptedModelProvider("scripted").withUsagePerCall(new ModelUsage().withInputTokens(1200L).withOutputTokens(300L));
|
||||
new MarketingQBitProducer().withQBitConfig(new MarketingQBitConfig().withBackendName(BACKEND).withModelProvider(model).withModel("scripted-model")).produce(qInstance);
|
||||
|
||||
seed(qInstance, model);
|
||||
return (qInstance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the tables from the shipped H2 DDL.
|
||||
*******************************************************************************/
|
||||
static void createTables(RDBMSBackendMetaData backend) throws QException
|
||||
{
|
||||
try(Connection connection = ConnectionManager.getConnection(backend); Statement statement = connection.createStatement();
|
||||
InputStream ddl = MarketingDemoServer.class.getResourceAsStream("/marketing-h2.sql"))
|
||||
{
|
||||
String sql = new String(ddl.readAllBytes(), StandardCharsets.UTF_8);
|
||||
for(String part : sql.split(";"))
|
||||
{
|
||||
String trimmed = part.lines().filter(l -> !l.trim().startsWith("--")).reduce("", (a, b) -> a + "\n" + b).trim();
|
||||
if(!trimmed.isEmpty())
|
||||
{
|
||||
statement.execute(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Could not create the demo tables", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Starter data, then history through the real services and processes.
|
||||
*******************************************************************************/
|
||||
static void seed(QInstance qInstance, ScriptedModelProvider model) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.init(qInstance, DemoSessionCustomizer.sessionFor(USER));
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// the qbit's own "Load Verified Starter Data" table action //
|
||||
////////////////////////////////////////////////////////////////
|
||||
requireNoException(new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingTableProcesses.LOAD_STARTER_DATA)
|
||||
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)), "starter data");
|
||||
|
||||
QRecord product = first(MarketingProduct.TABLE_NAME, "slug", "qqq");
|
||||
QRecord campaign = first(MarketingCampaign.TABLE_NAME, "name", "QQQ 4.0: from quickstart to your first table");
|
||||
QRecord sink = first(MarketingChannel.TABLE_NAME, "name", "Controlled test sink");
|
||||
Integer campaignId = campaign.getValueInteger("id");
|
||||
|
||||
requireNoException(runAction(MarketingActions.CHECK_CAMPAIGN_READINESS, campaignId), "campaign readiness");
|
||||
requireNoException(runAction(MarketingActions.CHECK_CAMPAIGN_READINESS, first(MarketingCampaign.TABLE_NAME, "name", "gclo 1.1 for Windows: mirror a whole GitHub org").getValue("id")), "campaign readiness");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Draft with Agent, offline: the scripted model reads, then saves a draft //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
Integer released = MarketingStore.query(MarketingClaim.TABLE_NAME, new QQueryFilter(
|
||||
new QFilterCriteria("productId", QCriteriaOperator.EQUALS, product.getValue("id")),
|
||||
new QFilterCriteria("status", QCriteriaOperator.EQUALS, ClaimStatus.RELEASED.getId()))).get(0).getValueInteger("id");
|
||||
model.thenToolUses("Reading the brief first.", List.of(
|
||||
new Object[] { MarketingTools.GET_CAMPAIGN_BRIEF, Map.of("campaign_id", campaignId) },
|
||||
new Object[] { MarketingTools.LIST_PRODUCT_CLAIMS, Map.of("product_id", product.getValueInteger("id")) },
|
||||
new Object[] { MarketingTools.LIST_CHANNELS, Map.of() }))
|
||||
.thenToolUse(MarketingTools.SAVE_DRAFT, Map.of("campaign_id", campaignId, "channel_id", sink.getValueInteger("id"), "title", "Try QQQ 4.0 in a few minutes",
|
||||
"body", BODY, "claim_ids", List.of(released)))
|
||||
.thenText("I saved one draft for review on the controlled test sink.");
|
||||
new MarketingAgentService().run(AgentTask.DRAFT_CONTENT, campaignId, null, null);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// a person schedules it, submits it, approves it and schedules it; the //
|
||||
// worker then runs the due execution on the controlled test sink //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
Integer contentId = first(MarketingContent.TABLE_NAME, "campaignId", campaignId).getValueInteger("id");
|
||||
MarketingStore.update(MarketingContent.TABLE_NAME, new QRecord().withValue("id", contentId).withValue("scheduledFor", Instant.now().minusSeconds(5)));
|
||||
requireNoException(runAction(MarketingActions.SUBMIT_FOR_REVIEW, contentId), "submit for review");
|
||||
requireNoException(runAction(MarketingActions.APPROVE, contentId), "approve");
|
||||
requireNoException(runAction(MarketingActions.SCHEDULE, contentId), "schedule");
|
||||
requireNoException(new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingTableProcesses.RUN_DUE_EXECUTIONS)
|
||||
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)), "run due executions");
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// a second piece of content, waiting for review //
|
||||
///////////////////////////////////////////////////
|
||||
Integer waiting = MarketingStore.insert(new MarketingContent().withCampaignId(campaignId).withChannelId(sink.getValueInteger("id")).withTitle("Your first table, step by step")
|
||||
.withBody("The quickstart takes you from an empty project to your first table. Java 21, Docker and Bash are required.")
|
||||
.withScheduledFor(Instant.now().plusSeconds(3600L * 24))).getValueInteger("id");
|
||||
requireNoException(runAction(MarketingActions.SUBMIT_FOR_REVIEW, waiting), "submit for review");
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// controlled fixture events (test data, never counted as real numbers) //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
for(int i = 1; i <= 5; i++)
|
||||
{
|
||||
MarketingStore.insert(new MarketingOutcomeEvent().withCampaignId(campaignId).withStage(FunnelStage.AWARENESS.getId()).withEventType("try_page_visit")
|
||||
.withSource("fixture").withExternalEventId("visit-" + i).withActorKey("visitor-" + (i % 3)).withIsTestData(true));
|
||||
}
|
||||
MarketingStore.insert(new MarketingOutcomeEvent().withCampaignId(campaignId).withStage(FunnelStage.ENGAGEMENT.getId()).withEventType("quickstart_opened")
|
||||
.withSource("fixture").withExternalEventId("open-1").withActorKey("visitor-1").withIsTestData(true));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first record matching one field.
|
||||
*******************************************************************************/
|
||||
private static QRecord first(String table, String field, Serializable value) throws QException
|
||||
{
|
||||
return (MarketingStore.queryBy(table, field, value).get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a record action headlessly on one record.
|
||||
*******************************************************************************/
|
||||
private static RunProcessOutput runAction(String processName, Serializable id) throws QException
|
||||
{
|
||||
return (new RunProcessAction().execute(new RunProcessInput().withProcessName(processName).withCallback(QProcessCallbackFactory.forPrimaryKey("id", id))
|
||||
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail the demo start-up if a seeding step failed.
|
||||
*******************************************************************************/
|
||||
private static void requireNoException(RunProcessOutput output, String what) throws QException
|
||||
{
|
||||
if(output.getException().isPresent())
|
||||
{
|
||||
throw (new QException("Demo seeding failed at [" + what + "]", output.getException().get()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every mock session is Dana, who holds every marketing permission.
|
||||
*******************************************************************************/
|
||||
public static class DemoSessionCustomizer implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
|
||||
{
|
||||
QSession granted = sessionFor(USER);
|
||||
qSession.setUser(granted.getUser());
|
||||
qSession.setPermissions(granted.getPermissions());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void customizeAutomatedSessionForUser(QInstance qInstance, QSession automatedSessionForUser, Serializable userId)
|
||||
{
|
||||
automatedSessionForUser.setPermissions(sessionFor(String.valueOf(userId)).getPermissions());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Dana's session.
|
||||
***************************************************************************/
|
||||
static QSession sessionFor(String userId)
|
||||
{
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference(userId).withFullName("Dana Demo"));
|
||||
session.setPermissions(new java.util.HashSet<>(MarketingPermissions.ALL));
|
||||
return (session);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
bundle: test-host-override
|
||||
version: "1"
|
||||
layer: HOST
|
||||
entries:
|
||||
- key: table:marketing_claim;slot:help
|
||||
content: host text
|
||||
@@ -434,6 +434,28 @@ CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_campaign_id ON marketing_o
|
||||
CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_content_id ON marketing_outcome_event (content_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_marketing_outcome_event_execution_id ON marketing_outcome_event (execution_id);
|
||||
|
||||
-- A persisted daily rollup of a campaign's outcome events per stage and event type (system-written, recomputable from the raw events).
|
||||
CREATE TABLE IF NOT EXISTS marketing_metric_daily (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
campaign_id INTEGER NOT NULL,
|
||||
product_id INTEGER,
|
||||
day_utc DATE NOT NULL,
|
||||
funnel_stage INTEGER NOT NULL,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
is_test_data BOOLEAN NOT NULL,
|
||||
raw_count INTEGER,
|
||||
unique_actors INTEGER,
|
||||
events_without_actor INTEGER,
|
||||
recomputed_at TIMESTAMP,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_marketing_metric_daily PRIMARY KEY (id),
|
||||
CONSTRAINT uq_marketing_metric_daily_1 UNIQUE (campaign_id, day_utc, funnel_stage, event_type, is_test_data)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_marketing_metric_daily_campaign_id ON marketing_metric_daily (campaign_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_marketing_metric_daily_product_id ON marketing_metric_daily (product_id);
|
||||
|
||||
-- Runtime switches and limits: agent and execution kill switches, provider, model, budgets, concurrency.
|
||||
CREATE TABLE IF NOT EXISTS marketing_policy (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
|
||||
Reference in new issue
Block a user