Compare commits

...
Author SHA1 Message Date
james.maesandCodex b199b24f01 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:09:27 -05:00
james.maesandCodex b5308a8bab ci: adopt central orb 0.7.12 and stable gates (#37)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:50:12 -05:00
james.maesandCodex 0caaa3717e ci: centralize rc release completion in qqq orb (#36)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:23:46 -05:00
james.maesandCodex 464db76bc3 ci: adopt shared orb 0.7.11 (#35)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:54:30 -05:00
james.maes 19a06166f5 Merge pull request #34 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb 0.7.4
2026-10-20 19:32:48 -05:00
james.maesandCodex 61060e2793 ci: adopt central QQQ orb 0.7.4
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:26:47 -05:00
james.maes 4e88ed8303 Merge pull request #31 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:03:57 -05:00
james.maesandClaude Opus 5.5 7a7b804106 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:19:25 -05:00
james.maes 4c95151a86 Merge pull request #29 from QRun-IO/feature/ui-api-1.6
feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.15)
2026-10-15 16:12:53 -05:00
james.maesandClaude Opus 5.5 41bbf2c428 chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:27:04 -05:00
james.maesandClaude Opus 5.5 2f6be765f2 feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.14)
Regenerated against UI API 1.6.0: every operation has an operationId and
the standard error responses (BasicErrorResponseV1: error, code, message,
fieldErrors, traceId); the extension's own error schema is kept but
deprecated; hand-written error bodies go through QJavalinUtils so they carry
code/message/traceId. Extension MINOR bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:37 -05:00
james.maes 92dd901409 Merge pull request #28 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:56 -05:00
james.maesandClaude Opus 5.5 6a4ea584f5 fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-collaboration.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:27:45 -05:00
james.maes 03b3bbaca2 Merge pull request #27 from QRun-IO/fix/help-bundle-version-test
test(help): bundle version test compares with the artifact version
2026-10-14 23:18:42 -05:00
james.maesandClaude Opus 5.5 31ce528064 test(help): compare the help bundle version with the artifact version, not a hardcoded SNAPSHOT
The bundle version is now Maven-filtered, so a literal SNAPSHOT in the test
fails every release build. Surefire passes the artifact version in
qbit.artifactVersion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:07:29 -05:00
james.maes 90b3956cf7 Merge pull request #26 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 e4f92192c6 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:04:44 -05:00
james.maes 675d30568e Merge pull request #25 from QRun-IO/feature/help-bundle
feat(help): qbit-collaboration help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:45:30 -05:00
james.maesandClaude Opus 5.5 3df535453e feat(help): ship the qbit-collaboration help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:27:03 -05:00
james.maes d302f1dc1f Merge pull request #24 from QRun-IO/feature/tables-populated-defaults
feat: Create rows from configuration, Setup card and table help
2026-10-14 17:44:57 -05:00
james.maesandClaude Opus 5.5 58c87b0378 fix: load feature settings in one transaction, correct scope wording, count only eligible tables
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:39:52 -05:00
james.maesandClaude Opus 5.5 e5647e6020 chore: well-formed STAND-IN marker for the help applier
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:30:31 -05:00
james.maesandClaude Opus 5.5 3d2a1ded72 feat: Create rows from configuration, Setup card and table help so Feature Settings is never a mystery
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:08:52 -05:00
james.maes 662a9f1a6d Merge pull request #23 from QRun-IO/feature/qqq-rc7
feat: QQQ RC.7 followers provider and secured-data label rules
2026-10-14 16:33:33 -05:00
james.maesandClaude Opus 5.5 e014c00428 feat: QQQ 4.1.0-RC.7: record followers provider and secured-data label rules
CollaborationFollowersProvider implements core RecordFollowersProviderInterface
over collaboration_follow (CHANGE/ALERT -> notifyOnChange, COMMENT ->
notifyOnComment, muted excluded), registered via RecordFeaturesMetaData, so
qbit-alerts can notify followers. Cached favorite/recent labels are stored
only when FieldAccessHelper.isRecordLabelFullAccess holds; notification
titles fall back to the table label and id for recipients without FULL
label access. Timeline masking (MASKED shows the mask, HIDDEN omitted,
canary never leaks) is covered by tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:29:09 -05:00
james.maes ea0b74f94c Merge pull request #22 from QRun-IO/fix/issues-20-21
fix: context-free attachment steps (#20) and no fixed home column widths (#21)
2026-10-14 14:59:18 -05:00
james.maesandClaude Opus 5.5 e051c74273 fix: context-free attachment action steps and no fixed home column widths
DeleteAttachmentsProcess Review/Delete create their AttachmentsService on
first use instead of in the constructor, so QQQ can build them during
context-free instance validation (fixes #20). Home table columns no
longer set widths, so narrow cards wrap instead of scrolling (fixes #21).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:56:09 -05:00
james.maes 680fd5fa02 Merge pull request #18 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:56:55 -05:00
james.maes 1e0a5773bb Merge pull request #19 from QRun-IO/feature/email-api
refactor(email): use qbit-email's public api package; no G1 allow-list
2026-10-14 10:25:07 -05:00
james.maesandClaude Opus 5.5 d5adcec567 refactor(email): use qbit-email's public api package; no G1 allow-list
Moves the optional email integration from qbit-email internals to
com.kingsrook.qbits.email.api (qbit-email 0.1.0-RC.2) with identical
behaviour, and moves to qbit-build-parent 2.1.1, whose default G1
allow-lists include qbit-email's API (QRun-IO/qbit-bom#12), so the
per-repo allow-lists are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:20:32 -05:00
james.maesandClaude Opus 5.5 83b253a94f ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:06:36 -05:00
james.maes 4eb5b70ade Merge pull request #17 from QRun-IO/feature/object-storage-rc4
feat(attachments): bucket upload cap via object storage api (RC.4)
2026-10-14 09:58:45 -05:00
james.maesandClaude Opus 5.5 da932fb309 feat(attachments): honor the bucket upload cap via object storage's api (qbit-object-storage 0.1.0-RC.4)
AttachmentStorage.maxBytes now reads StorageBucket.maxUploadBytes() from
the api package (null = no bucket limit); uploads stop at the smaller of
the bucket cap and the per-table cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:55:21 -05:00
james.maes 66189ec110 Merge pull request #15 from QRun-IO/feature/email-qbit
feat(email): email mentions, shares and task notices through qbit-email
2026-10-14 08:27:25 -05:00
james.maesandClaude Opus 5.5 23d3365437 Merge develop into feature/email-qbit
Keeps develop's changes and re-applies the optional qbit-email 0.1.0-RC.1
delivery on top (adapted to develop's refactors; guardrail allow-lists
for the optional integration where the build parent enforces them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:19:03 -05:00
james.maesandClaude Opus 5.5 f0b8fc9141 build: qbit-email 0.1.0-RC.1 from Maven Central
Moves from 0.1.0-SNAPSHOT to the first release candidate. Test schemas
gain the RC's email_provider_sender_address table and the message
from_address_id column where they copy the qbit's DDL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:58:55 -05:00
james.maes 6cd78a803e Merge pull request #16 from QRun-IO/fix/live-review
fix: live-review fixes (tag id filter, home widgets, display names, demo multi-user)
2026-10-14 07:09:47 -05:00
james.maes b4032baf70 Merge pull request #14 from QRun-IO/feature/demo-dev-server
test: collaboration demo dev server with seeded data
2026-10-14 07:09:35 -05:00
james.maes d2ad22eb97 Merge pull request #13 from QRun-IO/feature/presence-custom-fields
feat: custom field designer and presence
2026-10-14 07:09:21 -05:00
james.maes 66b5bf2bea Merge pull request #8 from QRun-IO/feature/ui-api-endpoints
feat: adopt core collaboration contracts and the UI API extension endpoints
2026-10-14 07:09:09 -05:00
james.maesandClaude Opus 5.5 d828d1677d feat: friendly audit summaries on the activity timeline
Core's "Record was Edited/Inserted/Deleted" audit messages become "Edited by
Alice Morgan: Title, Total", "Created by Alice Morgan" and "Deleted by ...",
using personalized field labels (first four, then "and N more"). Detail rows
and unrecognized messages are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:28:43 -05:00
james.maesandClaude Opus 5.5 ef2ee45b5a feat: demo server picks the user per browser session and sends the real name
A ?user= request parameter (remembered in the collab_dev_user cookie) chooses the
demo identity for that browser, falling back to -Dcollab.dev.user. The session
now carries values.user (name and email) so the frontend shows Alice Morgan
instead of Anonymous. README documents opening a second browser to see presence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:27:08 -05:00
james.maesandClaude Opus 5.5 03669391d0 fix: exclude the caller from recipient and mention search; demo links are absolute
People search (share recipients and mention candidates) never returns the
current user. The record link already honors frontendBaseUrl; the demo server
now sets it to http://localhost:<port> and the test asserts the absolute URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:23:44 -05:00
james.maesandClaude Opus 5.5 7e63314413 feat: display names beside user ids in API payloads and admin tables
Tasks, attachments, comments' revisions, followers, shares, access requests and
assignments gain additive *DisplayName properties, resolved with one directory
query per response (unknown ids fall back to the id). User-id fields on the
collaboration tables use the collaboration_user possible value source, keyed on
userId, so admin screens show names. Contract regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:22:00 -05:00
james.maesandClaude Opus 5.5 68fe96c22f feat: rebuild the Collaboration home from standard widgets
Replace the HTML wall of text with a KPI block (comments this week, open
threads, open and overdue tasks, open access requests, attachments with a
human-readable size, active custom fields) and full-width Tags, Custom fields
and Who's here tables, each with a real empty state and no hard-coded colors.
Counts still use the caller's session. Tests updated to assert widget data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:14:27 -05:00
james.maesandClaude Opus 5.5 c9954efef4 feat(email): email mentions, shares and task notices through qbit-email
Phase (d) of QRun-IO/qbit-email docs/DESIGN.md section 11. qbit-email is an
optional dependency (0.1.0-SNAPSHOT until its first RC) used through one
adapter class behind a classpath and instance guard; without it the
existing behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:12:39 -05:00
james.maesandClaude Opus 5.5 e696d9edaf fix: demo Admin parent app is reachable so Collaboration lands in the app tree
The producer already nests the app under its parent, or leaves it top-level.
QQQ builds the appTree from apps whose parents survive permission checks, so a
denied parent silently orphaned Collaboration (present in apps, absent from the
tree). The demo granted admin.access while the Admin app requires
admin.hasAccess. Tests assert the tree for both placements and for the demo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:07:19 -05:00
james.maesandClaude Opus 5.5 42cf51325a fix: tag filter accepts ids as any JSON number or numeric string
TagCriteriaExpander treated non-Number values as names, so ids arriving
as strings from v1 JSON matched nothing. Numbers and numeric strings that
match an existing tag are ids; everything else is a normalized name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:04:54 -05:00
james.maes 95506ed706 Merge branch 'feature/presence-custom-fields' into feature/demo-dev-server 2026-10-13 22:19:51 -05:00
james.maesandClaude Opus 5.5 7b7c921079 test: collaboration demo dev server with seeded Sales data, plus a SELECT custom-field refresh fix
CollaborationDevServer serves the Next dashboard over an in-memory
instance (people, orders, every record feature on, seeded comments,
files, tags, custom fields, tasks, shares, audit history) with mock users
alice, bob and admin. CustomFieldsProvider now registers generated SELECT
possible value sources on refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:04:03 -05:00
72 changed files with 7205 additions and 610 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
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.7.12
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.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -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
+62 -13
View File
@@ -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
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.7.12
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
+32 -1
View File
@@ -4,6 +4,37 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
## [Unreleased]
### Added
- Feature Settings table action **Create rows from configuration**: writes the code configuration out as editable `collaboration_feature_setting` rows (insert-only, only when the table is empty, no change in behavior).
- Setup card on the Collaboration home: each record feature, whether it is on, for which tables, and where that comes from.
- Host-neutral QBIT help bundle covering tables, fields, sections, built-in actions, the app, widgets and possible-value sources; core registration and `HelpBundleAssertions` coverage replace the temporary help loader.
- `CollaborationTablesGuardrailTest` and `FeatureSettingsDefaultsTest`.
### Changed
- UI API extension collaboration 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and published presence request/snapshot/viewer schemas to resolve dangling references. Retain CollaborationErrorV1 as deprecated.
- Upgrade the default QQQ BOM to `4.1.0-RC.7` from Maven Central.
- Register the core followers provider for alerts and other qbits, with event preferences, future-mute exclusion, system reads scoped to one record, and caller-owned recipient access checks.
- Cache favorite/recent record labels only for FULL field access. Resolve comment/share notification labels per recipient with a table-label-and-id fallback. Use the typed core label-access API for email labels as well.
- Verify RC.7 timeline audit masking and HIDDEN-field omission through enforced viewer queries, including canaries in values and messages.
- Switch the optional qbit-email integration to 0.1.0-RC.2 and its published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and enqueue-status handling. Narrow the email package allowance to the public API.
- Email through qbit-email 0.1.0-RC.1: new optional dependency and `EmailCollaborationNotifier` (templates `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder`), the default notifier when qbit-email is produced in the instance and no `notifier` is configured; `NoOpNotifier` otherwise. One NOTIFICATION message per recipient, no comment bodies, record labels only when field access allows. New `recipientEmailResolver` config hook; `CollaborationNotification` gains optional `sourceId` and `actorUserId`.
### Fixed
- QBit metadata reports the Maven artifact version from a filtered resource, including release versions.
- Timeline audit summaries read "Edited by Alice Morgan: Title, Total" or "Created by Alice Morgan" instead of "Record was Edited"; detail rows are unchanged.
- The demo server chooses its user per browser session (`?user=bob`, remembered in a cookie) instead of once per JVM, and sends the person's name to the frontend so it no longer shows Anonymous.
- The share recipient search and mention candidates no longer offer the caller to themself.
- The demo server configures `frontendBaseUrl`, so record links are absolute URLs (the API already returned absolute links once it is set).
- Tag filters accept tag ids as any JSON number or numeric string; other strings remain names.
- Collaboration home rebuilt from standard KPI and table widgets with empty states; the demo Admin parent app is reachable so Collaboration appears in the app tree.
- Dependent possible values for comment parents (thread root/replies), feature-setting scope names (apps/eligible tables/instance), and comment field anchors (session-visible fields), with RC.5 action-level tests.
### Added
@@ -23,7 +54,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
- Adopt core feature overrides, record capabilities and implied WRITE locks, reference metadata, mirrored locks, after-commit cleanup and summaries. Core registers feature permissions in the policy editor.
- Replace `OtherUserAccess`/`UserSessionSupplier` with `CollaborationRecordAccessChecker` and core `UserSessionProviderInterface`; configure `withUserSessionProvider`.
- Remove `ParentTableRestriction` and `TagFilterTranslator`; runtime `collaborationTags` filtering/search uses core subqueries. Retain the unpinned visibility fallback still missing from core.
- Publish identifier-only realtime hints after commit. Selectable Tags output and the optional followers SPI await missing core hooks.
- Publish identifier-only realtime hints after commit. Selectable Tags output awaits a core hook; the followers SPI is adopted above.
- Build against locally installed QQQ `4.1.0-SNAPSHOT` until RC.6 is released; `qqq-middleware-javalin` is a compile dependency. Object storage remains pinned to `0.1.0-RC.2`.
+145 -14
View File
@@ -4,6 +4,23 @@ Record collaboration for QQQ: attachments, tags, custom fields, comments with me
Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md).
## In-app help
The qbit registers [help/qbit-collaboration.yaml](src/main/resources/help/qbit-collaboration.yaml)
with QQQ's help-bundle registry. Core startup sync applies it when the host has the
`helpContent` table; registration is safe without that table. Update the bundle's
literal version alongside the POM version when releasing.
To customize text, register a separate bundle with `layer: HOST` and the same entry
key and role using `HelpBundles.register(instance, "help/my-host.yaml")`. HOST text
takes precedence over QBIT text; startup sync preserves user edits. Hosts should
supply help for their configured table-specific follow, tag and assignment actions,
and for a renamed Collaboration app.
`CollaborationHelpBundleTest` runs `HelpBundleAssertions` against the produced
metadata and checks widget and possible-value-source coverage. The table guardrail
also checks that every table explains how its rows are created. Run `./mvnw -q verify`.
## Dependent fields
Comment parents list the selected thread's root and replies. Feature-setting scope names follow `scopeType` (apps, eligible tables, or `instance`). Comment field anchors follow `tableName` and offer non-hidden fields visible to the session. These use RC.5 filter variables and custom providers receiving `otherValues`; automatic dependency refresh awaits core `dependsOn`. See [dependent-field details](docs/CONTINGENT-POSSIBLE-VALUES.md).
@@ -11,10 +28,100 @@ Comment parents list the selected thread's root and replies. Feature-setting sco
## Requirements
- Java 21.
- QQQ `4.1.0-SNAPSHOT` installed from develop, including UI API extension point #1128. The build will move to `4.1.0-RC.6` once released; middleware Javalin is a compile dependency.
- QQQ `4.1.0-RC.7` from Maven Central; middleware Javalin is a compile dependency.
- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use the memory backend.
- Attachments require `qbit-object-storage:0.1.0-RC.2` (a pinned dependency), installed with a PRIVATE bucket alias matching `attachmentsBucketName`. Configure its connection and credentials in the host; this qbit does not provision buckets.
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-SNAPSHOT`, targeting RC.6.
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-RC.7`.
## Followers and secured data
The instance registers a core `RecordFollowersProviderInterface` backed by
`collaboration_follow`, so alerts and other qbits can use `RecordFollowers.get`.
CHANGE and ALERT select `notifyOnChange`; COMMENT selects `notifyOnComment`.
Future mutes are excluded, and disabling FOLLOW returns no subscribers. The
provider reads only the requested table/record as the system. Consumers must
check each recipient's record access before sending data.
Favorites and recents cache a record label only when every label field is FULL
for the current session; otherwise the stored label is null. Lists continue to
re-read labels as the viewer. Comment and share notification titles resolve
labels in each recipient's session and fall back to the table label and id
(for example, `Order #1042`) when label access is restricted or unavailable.
Timeline audit reads use core's enforced query boundary: MASKED values are
masked, HIDDEN details are omitted, and restricted audit messages are cleared.
Comment bodies remain user content; realtime field-name filtering is handled by core.
## Demo server
`CollaborationDevServer` is a disposable, memory-backed Sales CRM for trying the
Next dashboard and taking screenshots. It needs Java 21 and QQQ `4.1.0-RC.7` from Maven Central. No database, Docker or MinIO is needed. It binds
to `127.0.0.1`; stopping the JVM discards edits and uploaded attachments.
Build the Next static export in `qqq-frontend-next`:
```bash
QQQ_NEXT_OUTPUT=export pnpm build
```
In PowerShell, use `$env:QQQ_NEXT_OUTPUT = 'export'; pnpm build` for that command.
Then, from this repository, prepare the test classpath and place the export under
a classpath directory named `next-dashboard`, following StorageDevServer's layout:
```powershell
.\mvnw.cmd -o -q test-compile dependency:build-classpath '-Dmdep.includeScope=test' '-Dmdep.outputFile=target/demo-classpath.txt'
New-Item -ItemType Directory -Force target/demo-frontend/next-dashboard | Out-Null
Copy-Item -Path ../qqq-frontend-next/out/* -Destination target/demo-frontend/next-dashboard -Recurse -Force
$demoDeps = (Get-Content target/demo-classpath.txt -Raw).Trim()
$demoClasspath = "target/test-classes;target/classes;$demoDeps;target/demo-frontend"
java '-Dqqq.javalin.frontend=next' '-Dcollab.dev.port=8098' '-Dcollab.dev.user=alice' -cp $demoClasspath com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer
```
The equivalent Java command is
`java -Dqqq.javalin.frontend=next -Dcollab.dev.port=8098 -Dcollab.dev.user=alice -cp "target/test-classes;target/classes;DEPS;EXPORT" com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer`.
`DEPS` is the generated classpath file's contents; `EXPORT` is the folder **containing**
`next-dashboard/`, not the export itself. On macOS/Linux use `./mvnw` and `:` instead
of `;` between classpath entries.
Open [the demo](http://localhost:8098/?createMockSession=true), then **Sales → People**
or **Sales orders**. [Person 1](http://localhost:8098/app/person/1) and
[order 1](http://localhost:8098/app/sales_order/1) have the richest collaboration panels.
Pick the user per browser: add `user=` to the page load, for example
`http://localhost:8098/?createMockSession=true&user=bob`. The server remembers the choice in a
`collab_dev_user` cookie, so **open a second browser with `?user=bob` to see presence** (your
first browser keeps its own user; browsers on the same profile share the cookie). Without a
choice, `-Dcollab.dev.user=alice|bob|admin` (one value, not the literal list) is the default.
The MOCK session customizer supplies stable identities, display names (the sidebar shows
Alice Morgan, not Anonymous) and real permissions, also used by recipient access checks.
Shared record links are absolute (`frontendBaseUrl` is `http://localhost:<port>`):
| User | Permissions |
| --- | --- |
| `alice` — Alice Morgan (default) | Full Sales access and comment, attach, tag, share, task and assign permissions |
| `bob` — Bob Chen | People read/comment; no Sales order access |
| `admin` — Demo Administrator | Full Sales access plus `collaboration.admin`, vocabulary/field management and Admin → Collaboration |
Both tables enable every record feature. People use FREE tags and host owner/assignee
fields; orders use APPROVED_ONLY tags. The seed contains 25 fictional contacts at
10 companies, 40 orders with deterministic amounts/dates, eight approved tags,
two ACTIVE custom fields (Preferred contact; searchable Purchase order no.), six
comments with three mentions and a resolved thread, three generated PNG/PDF attachments,
follows, four tasks (one overdue and one completed), two order assignments, two shares, Bob's open order-access
request, and six favorites/recents for Alice. Ordinary record updates create FIELD
audit history. Task deadlines are relative to startup so the overdue example stays useful.
Attachment previews/downloads use real in-memory bytes and expiring local URLs.
Notifications are recorded in memory; they do not send email. Presence is live and
ephemeral: the current browser supplies its heartbeat; there are no fake persistent viewers.
Two current permission edge cases remain visible in this snapshot: Bob can start a
comment thread, but replying to another author's thread fails its activity update;
an access request carrying a share ID for an unreadable parent returns 404. The seed
uses Bob's own thread and a direct access request without a share ID.
The test builds and seeds without frontend assets, validates the instance, then checks
the version/collaboration endpoints, downloads and Bob's restricted access on a free port:
```powershell
.\mvnw.cmd -o -q verify
```
## Install
@@ -43,6 +150,15 @@ new CollaborationQBitProducer()
Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation after all producers.
## Starter defaults and setup
Collaboration ships no SQL seed. The one configuration table, `collaboration_feature_setting`, depends on the host's configuration (`withDefaultFeatures`, `withTableFeatures`, the default tag mode, features declared on apps and tables), so its rows are computed at run time:
- Open Feature Settings and run the table action **Create rows from configuration** (collaboration administrators). It writes one Instance row per feature, plus an App or Table row wherever the code declares something at that scope. The rows say exactly what the code already says, so nothing changes; `FeatureSettingsDefaultsTest` proves every table resolves identically with and without them.
- It only creates rows when the table is empty, so it never touches an administrator's rows. Rows are applied after all code configuration. An Instance row overrides what code declares on any app or table that has no row of its own, so once rows exist, change features in the rows, not in code.
- The **Setup** card on the Collaboration home shows each feature, whether it is on, for which tables, and whether that comes from Feature Settings rows or the configuration.
- Every collaboration table has help (shown when it is empty) that says how its rows get created. The other tables hold user content or runtime data and start empty by design.
## Tables
| Table | Purpose |
@@ -66,7 +182,7 @@ Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation
All tables include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold.
The home reports persisted enabled settings by scope type, people known and people seen within the inclusive last seven days, plus Favorites and Recently viewed row counts and distinct user counts, attachment count and total bytes, comments in the last seven days, open root threads, and follower subscriptions (admins). Attachment aggregates are explicitly approximate until core can mirror parent locks. Recent activity is based on `lastSeenAt`, independently of the directory's `active` account flag. Empty states explain missing data. These counts do not represent resolved feature enablement on host tables.
The home is four full-width standard widgets (no custom HTML, no hard-coded colors, so dark mode works): an Overview of KPIs (comments this week, open threads, open and overdue tasks, open access requests, attachments with a human-readable total size such as 4.7 KB, active custom fields), a Tags table (label, table or All tables, records), a Custom fields table (name, table, status, index state) and an admin-only Who's here table of live viewer counts per table. Every block has a real empty-state message. Counts use the caller's session and permissions; attachment aggregates remain approximate until core can mirror parent locks.
## Permissions
@@ -196,7 +312,8 @@ UI API endpoints are described below. Runtime feature enablement, mirrored WRITE
| `defaultMaxBytes` | `104857600` (100 MiB) | Positive byte limit, capped by the bucket's `maxUploadBytes`. |
| `blockedExtensions` | Empty | Case-insensitive extension denylist, with or without leading dots. |
| `recentsPerUser` | `200` | Positive retention count per user. Enforced by the trim process. |
| `notifier` | `QCodeReference(NoOpNotifier.class)` | Implements `CollaborationNotifier`; replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released. |
| `notifier` | None | Implements `CollaborationNotifier`; replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released. When unset: `EmailCollaborationNotifier` if qbit-email is on the classpath and produced in the instance, otherwise `NoOpNotifier`. |
| `recipientEmailResolver` | People directory | `Function<String, String>` from user id to email address for the email notifier. Unset reads the `email` of the matching `collaboration_user` row; no address means no email. |
| `userSessionProvider` | None | `QCodeReference` implementing `UserSessionProviderInterface`; missing user sessions mean unknown access. |
| `followActionTables` | Empty | Existing host tables to receive standard Follow and Unfollow Review/Result processes. |
| `followChangeTables` | Empty | Existing host tables whose UPDATE events should produce five-minute follow digests. |
@@ -204,6 +321,18 @@ UI API endpoints are described below. Runtime feature enablement, mirrored WRITE
| `parentAppName` | None | Optional existing parent, such as `admin`. |
| `adminApp` | `true` | Set false to omit the app/home. Record child-list widgets remain available. |
## Email through qbit-email
qbit-email 0.1.0-RC.2 is an optional dependency (`<optional>true</optional>`). The integration uses its published `com.kingsrook.qbits.email.api` package for installation detection, template registration and sending. When it is on the classpath, produced in the same instance, and `notifier` is not configured, mentions, shares and task notifications are emailed by `EmailCollaborationNotifier`. Without qbit-email nothing changes: `NoOpNotifier` sends nothing. A configured `notifier` always wins.
The guardrail allowlist properties retain the object-storage API and `*-api` artifact allowances and add only the qbit-email artifact and `com.kingsrook.qbits.email.api` package. The current parent 2.0.0 does not execute guardrails; these properties are ready for a parent that does. Tests retain `EmailQBitProducer`/`EmailQBitConfig` only to assemble the host instance; G1 checks production classes and sources.
- Templates (NOTIFICATION, Markdown, owner `qbit-collaboration`), registered on first send and never overwritten: `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder` (task assignment, reminder and overdue). Follow digests are not emailed.
- One message per recipient, idempotency key `collaboration:<mention|share|task>:<event id>:<recipient user id>`, linked to the parent record, sender `qbit-collaboration`. A repeat is a duplicate. The event id is the mention, share, task or assignment row id (task reminders add the due field); `CollaborationNotification` carries it as `sourceId`, with the acting user as `actorUserId`.
- No comment bodies, task titles, notes or share messages are emailed, since they may hold PHI. The email says who did what on which record, with a link. The record label is read as the recipient (needs `userSessionProvider`) and used only when `FieldAccessHelper.isRecordLabelFullAccess` allows it (reflective guard); otherwise the text says "a record in <table label>". Variables are classified CONFIDENTIAL so the templates need no PHI approval.
- The link is sent only when absolute: set `frontendBaseUrl`.
- Recipient addresses come from `withRecipientEmailResolver(Function<userId, String>)`, else the people directory email. Refused or suppressed sends are logged by user id and status only; delivery problems never fail the collaboration action.
## Adopting in another app
1. Register the host backend and optional Admin app, then invoke the producer above.
@@ -281,13 +410,13 @@ The runtime provider adds this field and its search entry where TAGS is enabled.
## UI API
The producer registers extension `collaboration` version `1.0.0` at
The producer registers extension `collaboration` version `1.1.0` at
`/qqq/v1/qbit/collaboration`. It uses the signed-in user's session and existing
services. Disable registration with `new CollaborationQBitConfig().withUiApi(false)`.
Presence, shares, tasks, my-work, direct upload and thumbnail routes are not part
of this extension version.
Requires QQQ `4.1.0-RC.15` and UI API `1.6.0`. Operations carry stable operationIds,
the collaboration tag and standard error responses.
All successes return HTTP 200. All bodies have named `Collaboration*V1` schemas
All successes return HTTP 200. Success and request bodies have named `Collaboration*V1` schemas
in [the extension contract](src/main/resources/openapi/qqq-v1/collaboration.yaml).
The server publishes these alongside core at `/qqq/v1/openapi.yaml` and `.json`.
@@ -349,9 +478,11 @@ attachment filename; preview requests inline content. QQQ's current OpenAPI
`Response` model cannot declare the `Location` header, so this contract uses the
JSON URL alternative consistently.
A missing or invisible parent returns identical `404 {"error":"Not found"}`.
Missing write permissions return 403; invalid input returns 400 with an `error`
message through QQQ's exception mapper. Provider/backend failures remain server
A missing or invisible parent returns 404 with the same `Not found` error message.
Missing write permissions return 403; invalid input returns 400 through QQQ's
exception mapper. Errors use `BasicErrorResponseV1` with `error`, `code`, `message`,
`traceId` and optional `fieldErrors`. `CollaborationErrorV1` remains defined but is
deprecated since 1.1.0. Provider/backend failures remain server
errors. Services continue to own author, record-lock and permission checks.
Mention candidates expose `canRead` as the string enum `"true"`, `"false"`,
`"unknown"`; unknown does not imply the recipient can read the record.
@@ -477,7 +608,7 @@ host integrations as other collaboration tables.
## Core contracts and completed UI API
The qbit targets QQQ RC.6 using the locally installed `4.1.0-SNAPSHOT`. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata:
The qbit uses QQQ `4.1.0-RC.7` from Maven Central. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata:
```java
config.withDefaultFeatures(RecordFeature.FAVORITES, RecordFeature.RECENTS, RecordFeature.TIMELINE)
@@ -507,9 +638,9 @@ New routes, all under `/qqq/v1/qbit/collaboration`:
| PUT | `/records/{table}/{pk}/assignment` |
| GET | `/my-work` |
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension stays at unreleased **1.0.0**; contract tooling accepts these additions without a bump.
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension is now **1.1.0**, including the UI API 1.6.0 error contract and published presence schemas.
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. The optional followers provider is skipped because `RecordFollowersProviderInterface` is absent from the installed jar. Selectable Tags values remain unavailable because the overlay has no output hook.
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. `CollaborationFollowersProvider` supplies the core followers SPI for alerts and other consumers. Selectable Tags values remain unavailable because the overlay has no output hook.
## Custom fields (phase f)
+8
View File
@@ -0,0 +1,8 @@
# Upgrading
## Unreleased
No schema change and nothing required of hosts.
- New table action **Create rows from configuration** on Feature Settings. Run it once if you want the effective feature settings visible and editable as rows. It changes no behavior and does nothing once the table has any row. Note that after rows exist they win over later changes to `withDefaultFeatures` or `withTableFeatures` in code, so change those settings in the rows from then on.
- New **Setup** widget first on the Collaboration home, and table help text. Hosts that override help on a table keep their own.
+48 -4
View File
@@ -22,12 +22,14 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<!-- qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -74,12 +76,25 @@
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-object-storage</artifactId>
<version>0.1.0-RC.2</version>
<version>0.1.0-RC.4</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
</dependency>
<!-- optional: email through qbit-email when it is produced in the host instance (EmailCollaborationNotifier) -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<optional>true</optional>
<exclusions>
<exclusion>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
@@ -98,7 +113,36 @@
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-collaboration.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-collaboration.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<!-- the artifact version, for the help bundle version test -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -181,14 +225,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.15 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -25,7 +25,7 @@ package com.kingsrook.qbits.collaboration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.notify.NoOpNotifier;
import java.util.function.Function;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
@@ -46,7 +46,8 @@ public class CollaborationQBitConfig implements QBitConfig
private Map<String, List<RecordFeature>> tableFeatures = new LinkedHashMap<>();
private List<String> mirroredSecurityKeyTypes = List.of();
private QCodeReference notifier = new QCodeReference(NoOpNotifier.class);
private QCodeReference notifier = null;
private Function<String, String> recipientEmailResolver = null;
private QCodeReference userSessionProvider = null;
private String defaultTagMode = "FREE";
private List<String> tagActionTables = List.of();
@@ -301,7 +302,6 @@ public class CollaborationQBitConfig implements QBitConfig
assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors);
}
assertCondition(List.of("FREE", "APPROVED_ONLY").contains(defaultTagMode == null ? "" : defaultTagMode), "defaultTagMode must be FREE or APPROVED_ONLY", errors);
assertCondition(notifier != null, "notifier is required", errors);
for(List<String> tables : List.of(followActionTables, followChangeTables, tagActionTables, assignActionTables))
{
for(String table : tables)
@@ -424,13 +424,32 @@ public class CollaborationQBitConfig implements QBitConfig
}
/*******************************************************************************
** Get notifier.
** Get notifier. Null (the default) means: email through qbit-email when it is
** produced in the instance, otherwise no delivery transport.
*******************************************************************************/
public QCodeReference getNotifier()
{
return (notifier);
}
/*******************************************************************************
** Get the user id to email address lookup used by the email notifier.
*******************************************************************************/
public Function<String, String> getRecipientEmailResolver()
{
return (recipientEmailResolver);
}
/*******************************************************************************
** Configure how the email notifier finds a recipient address from a user id.
** Null (the default) uses the email stored in the collaboration people directory.
*******************************************************************************/
public CollaborationQBitConfig withRecipientEmailResolver(Function<String, String> recipientEmailResolver)
{
this.recipientEmailResolver = recipientEmailResolver;
return (this);
}
/*******************************************************************************
** Configure notifier.
*******************************************************************************/
@@ -22,6 +22,9 @@
package com.kingsrook.qbits.collaboration;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.attachments.DeleteAttachmentsProcess;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
@@ -29,6 +32,7 @@ import com.kingsrook.qbits.collaboration.recents.TrimRecentsProcess;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
@@ -43,13 +47,41 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-collaboration";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private CollaborationQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Read the artifact version from the Maven-filtered properties resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = CollaborationQBitProducer.class.getClassLoader().getResourceAsStream("qbit-collaboration.properties"))
{
if(inputStream == null)
{
throw new IllegalStateException("Missing qbit-collaboration.properties; build with Maven to generate the artifact version.");
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw new IllegalStateException("Missing or unfiltered version in qbit-collaboration.properties; build with Maven resource filtering.");
}
return (version);
}
catch(IOException e)
{
throw new IllegalStateException("Could not read the artifact version from qbit-collaboration.properties.", e);
}
}
/*******************************************************************************
** Configure the qbit.
*******************************************************************************/
@@ -151,6 +183,7 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
com.kingsrook.qbits.collaboration.tags.TagProcesses.processesFor(table).forEach(output::add);
}
output.add(DeleteAttachmentsProcess.metadata());
output.add(FeatureSettingsDefaultsProcess.process());
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
{
output.add(source);
@@ -162,7 +195,7 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
}
if(Boolean.TRUE.equals(qBitConfig.getAdminApp()))
{
output.add(CollaborationHome.widget());
CollaborationHome.widgets().forEach(output::add);
output.add(CollaborationHome.app(qBitConfig));
}
output.addSelfToInstance(instance);
@@ -178,6 +211,8 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
QAppMetaData parent = instance.getApp(qBitConfig.getParentAppName());
parent.withChild(instance.getApp(qBitConfig.getAppName()));
}
// Registration is metadata-only; core startup sync skips hosts without helpContent.
HelpBundles.register(instance, "help/qbit-collaboration.yaml");
return (new MetaDataProducerMultiOutput());
}
}
@@ -68,6 +68,7 @@ public final class CoreCollaboration
{
config.applyFeatures(instance);
var features = RecordFeaturesMetaData.ofOrWithNew(instance);
features.withFollowersProvider(new QCodeReference(com.kingsrook.qbits.collaboration.follow.CollaborationFollowersProvider.class));
features.withOverrideProvider(new QCodeReference(FeatureSettingsProvider.class));
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.tags.TagsMetaDataProvider.class));
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.CustomFieldsProvider.class));
@@ -0,0 +1,278 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
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.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FeaturePermissionMode;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** The collaboration_feature_setting rows that say, in the database, exactly
** what the code configuration already says: one INSTANCE row per feature
** (enabled for the configured default features, disabled for the rest, with
** the default permission mode and the TAGS mode), plus an APP or TABLE row
** wherever the code declares something at that scope.
**
** Why rows computed at run time and not a SQL seed: the right rows depend on
** the host's configuration (withDefaultFeatures, withTableFeatures, the
** default tag mode, features declared on apps and tables), which SQL cannot
** see.
**
** Why every declaring scope gets its own row: runtime overrides are applied
** AFTER the code declarations, instance first, then app, then table. A bare
** INSTANCE row would therefore overwrite what a table declares in code.
** Restating each declaring scope in its own row keeps the resolved result of
** every table identical with and without the rows (FeatureSettingsDefaultsTest
** proves that for several configurations).
**
** Rows are only ever created when the table is empty, so they can never
** disagree with rows an administrator already wrote.
*******************************************************************************/
public final class FeatureSettingsDefaults
{
public static final String INSTANCE_SCOPE_NAME = "instance";
private static final ObjectMapper MAPPER = new ObjectMapper();
/*******************************************************************************
** Utility class.
*******************************************************************************/
private FeatureSettingsDefaults()
{
}
/*******************************************************************************
** The rows to create: the configured rows when the table is empty, none
** when an administrator already has rows.
*******************************************************************************/
public static List<QRecord> missingRows(QInstance instance, CollaborationQBitConfig config) throws QException
{
if(!CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter().withLimit(1)).isEmpty())
{
return (List.of());
}
return (configuredRows(instance, config));
}
/*******************************************************************************
** Every row the configuration implies (whether or not it exists yet).
*******************************************************************************/
public static List<QRecord> configuredRows(QInstance instance, CollaborationQBitConfig config)
{
List<QRecord> rows = new ArrayList<>();
///////////////////////////////////////////////////////////
// instance: every feature, so each one has a visible row //
///////////////////////////////////////////////////////////
RecordFeaturesMetaData instanceLevel = RecordFeaturesMetaData.of(instance);
for(RecordFeature feature : RecordFeature.values())
{
Map<String, Serializable> settings = new LinkedHashMap<>(declaredSettings(instanceLevel, feature));
if(feature == RecordFeature.TAGS)
{
settings.putIfAbsent("mode", config.getDefaultTagMode());
}
FeaturePermissionMode mode = declaredMode(instanceLevel, feature);
rows.add(row("INSTANCE", INSTANCE_SCOPE_NAME, feature, enabledThrough(feature, instanceLevel), mode == null ? FeaturePermissionMode.EDIT_OR_GRANTED : mode, settings));
}
/////////////////////////////////////////////////////////////////////////////
// apps and tables: only where the code declares something for the feature //
/////////////////////////////////////////////////////////////////////////////
for(QAppMetaData app : CollectionUtils.nonNullMap(instance.getApps()).values())
{
RecordFeaturesMetaData level = RecordFeaturesMetaData.of(app);
for(RecordFeature feature : declared(level))
{
rows.add(row("APP", app.getName(), feature, enabledThrough(feature, instanceLevel, level), modeThrough(feature, instanceLevel, level), declaredSettings(level, feature)));
}
}
for(QTableMetaData table : CollectionUtils.nonNullMap(instance.getTables()).values())
{
if(table.getName().startsWith("collaboration_"))
{
continue;
}
RecordFeaturesMetaData level = RecordFeaturesMetaData.of(table);
List<RecordFeaturesMetaData> apps = new ArrayList<>();
for(QAppMetaData app : CollectionUtils.nonNullMap(instance.getApps()).values())
{
boolean contains = CollectionUtils.nonNullList(app.getSections()).stream().anyMatch(section -> CollectionUtils.nonNullList(section.getTables()).contains(table.getName()));
if(contains && RecordFeaturesMetaData.of(app) != null)
{
apps.add(RecordFeaturesMetaData.of(app));
}
}
///////////////////////////////////////////////////////////////////////////
// a table row restates the whole chain (instance, its apps, the table). //
// Needed where the table declares something itself, and where several //
// apps declare the same feature: one row per app cannot restate the //
// combination, but the last override, the table row, can. //
///////////////////////////////////////////////////////////////////////////
Set<RecordFeature> features = new TreeSet<>(declared(level));
if(apps.size() > 1)
{
apps.forEach(app -> features.addAll(declared(app)));
}
List<RecordFeaturesMetaData> chain = new ArrayList<>();
chain.add(instanceLevel);
chain.addAll(apps);
chain.add(level);
RecordFeaturesMetaData[] levels = chain.toArray(new RecordFeaturesMetaData[0]);
for(RecordFeature feature : features)
{
Map<String, Serializable> settings = new LinkedHashMap<>();
for(RecordFeaturesMetaData each : levels)
{
settings.putAll(declaredSettings(each, feature));
}
rows.add(row("TABLE", table.getName(), feature, enabledThrough(feature, levels), modeThrough(feature, levels), settings));
}
}
return (rows);
}
/*******************************************************************************
** The features a scope declares anything about (enabled, mode or settings).
*******************************************************************************/
private static Set<RecordFeature> declared(RecordFeaturesMetaData level)
{
if(level == null)
{
return (Set.of());
}
return (Stream.of(level.getEnabled().keySet(), level.getPermissionModes().keySet(), level.getSettings().keySet())
.flatMap(Set::stream).collect(Collectors.toCollection(TreeSet::new)));
}
/*******************************************************************************
** Enabled as the code resolves it through the given levels, in order, the
** same way core does (a later level that declares the feature wins).
*******************************************************************************/
private static boolean enabledThrough(RecordFeature feature, RecordFeaturesMetaData... levels)
{
boolean enabled = false;
for(RecordFeaturesMetaData level : levels)
{
if(level != null && level.getEnabled().containsKey(feature))
{
enabled = Boolean.TRUE.equals(level.getEnabled().get(feature));
}
}
return (enabled);
}
/*******************************************************************************
** The permission mode through the given levels (a later declaration wins),
** EDIT_OR_GRANTED when none declares one, as core resolves it.
*******************************************************************************/
private static FeaturePermissionMode modeThrough(RecordFeature feature, RecordFeaturesMetaData... levels)
{
FeaturePermissionMode mode = FeaturePermissionMode.EDIT_OR_GRANTED;
for(RecordFeaturesMetaData level : levels)
{
if(declaredMode(level, feature) != null)
{
mode = declaredMode(level, feature);
}
}
return (mode);
}
/*******************************************************************************
**
*******************************************************************************/
private static FeaturePermissionMode declaredMode(RecordFeaturesMetaData level, RecordFeature feature)
{
return (level == null ? null : level.getPermissionModes().get(feature));
}
/*******************************************************************************
**
*******************************************************************************/
private static Map<String, Serializable> declaredSettings(RecordFeaturesMetaData level, RecordFeature feature)
{
return (level == null || level.getSettings().get(feature) == null ? Map.of() : level.getSettings().get(feature));
}
/*******************************************************************************
** One feature_setting record; no settings means a null settings column.
*******************************************************************************/
private static QRecord row(String scopeType, String scopeName, RecordFeature feature, boolean enabled, FeaturePermissionMode mode, Map<String, Serializable> settings)
{
QRecord record = new QRecord().withValue("scopeType", scopeType).withValue("scopeName", scopeName).withValue("feature", feature.name())
.withValue("enabled", enabled).withValue("permissionMode", mode == null ? null : mode.name());
if(!settings.isEmpty())
{
try
{
record.withValue("settings", MAPPER.writeValueAsString(settings));
}
catch(JsonProcessingException e)
{
throw (new IllegalStateException("Feature settings are not serializable", e));
}
}
return (record);
}
}
@@ -0,0 +1,274 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
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.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** "Create rows from configuration": a table action on Feature Settings that
** writes the code configuration out as editable rows (FeatureSettingsDefaults).
** It changes no behavior on its own, only makes the effective settings visible
** and editable. It does nothing once the table has any row. Standard streamed
** ETL: Review lists the rows that will be created, Result the rows created.
*******************************************************************************/
public final class FeatureSettingsDefaultsProcess
{
public static final String NAME = "collaborationCreateFeatureSettings";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private FeatureSettingsDefaultsProcess()
{
}
/*******************************************************************************
** The process: a table-level action (no records need to be selected).
*******************************************************************************/
public static QProcessMetaData process()
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.FEATURE_SETTING);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.FEATURE_SETTING);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
"These rows will be created from the current configuration. Nothing changes: the rows say what the configuration already says, and you can edit them afterwards.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
process.setName(NAME);
process.setLabel("Create rows from configuration");
process.setIcon(new QIcon("playlist_add"));
process.setTableName(CollaborationTables.FEATURE_SETTING);
process.setMinInputRecords(0);
process.setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("collaboration")
.withCustomPermissionChecker(new QCodeReference(AdminOnly.class)));
return (process);
}
/*******************************************************************************
** Collaboration administrators only (the table itself is admin-write).
*******************************************************************************/
public static class AdminOnly implements CustomPermissionChecker
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(session == null || !session.hasPermission(CollaborationPermissions.ADMIN))
{
throw (new QPermissionDeniedException("This needs the " + CollaborationPermissions.ADMIN + " permission."));
}
}
}
/*******************************************************************************
** Extract: the configured rows (none when the table already has rows).
*******************************************************************************/
public static class Extract extends AbstractExtractStep
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public Integer doCount(RunBackendStepInput input) throws QException
{
return (FeatureSettingsDefaults.missingRows(QContext.getQInstance(), CollaborationRecords.config()).size());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : FeatureSettingsDefaults.missingRows(QContext.getQInstance(), CollaborationRecords.config()))
{
getRecordPipe().addRecord(row);
}
}
}
/*******************************************************************************
** Transform: counts by scope for the review screen.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final Map<String, ProcessSummaryLine> byScope = new HashMap<>();
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
String scope = record.getValueString("scopeType");
byScope.computeIfAbsent(scope, s -> line(Status.OK, scope + " setting will be created", scope + " settings will be created",
scope + " setting was created", scope + " settings were created")).incrementCount();
output.getRecords().add(record);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
byScope.values().forEach(line -> line.addSelfToListIfAnyCount(lines));
if(lines.isEmpty())
{
lines.add(new ProcessSummaryLine(Status.INFO, null, "Feature settings already has rows, so nothing was created. Edit the rows directly."));
}
return (lines);
}
}
/*******************************************************************************
** Load: insert through the standard action with the administrator's own session.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine created = line(Status.OK, "setting was created", "settings were created", "setting was created", "settings were created");
/*******************************************************************************
** One transaction for the whole load: the override refresh waits for the
** commit, so a failure on a later page can never leave some rows (for
** example Instance rows without the App and Table rows that restate the
** code) in effect.
*******************************************************************************/
@Override
public Optional<QBackendTransaction> openTransaction(RunBackendStepInput input) throws QException
{
return (Optional.of(QBackendTransaction.openFor(new InsertInput(CollaborationTables.FEATURE_SETTING))));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
List<QRecord> inserted = new InsertAction().execute(new InsertInput(CollaborationTables.FEATURE_SETTING).withRecords(input.getRecords())
.withTransaction(getTransaction().orElse(null))).getRecords();
for(QRecord record : inserted)
{
if(record.getErrors() != null && !record.getErrors().isEmpty())
{
throw (new QException("Could not create feature settings: " + record.getErrors()));
}
created.incrementCount();
output.getRecords().add(record);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
created.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** A summary line with its four messages.
*******************************************************************************/
private static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
{
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
line.setSingularFutureMessage(singularFuture);
line.setPluralFutureMessage(pluralFuture);
line.setSingularPastMessage(singularPast);
line.setPluralPastMessage(pluralPast);
return (line);
}
}
@@ -35,6 +35,7 @@ import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -55,7 +56,7 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Resolves targets only with released RC.5 APIs. Each list batches record label
** Resolves targets through core permission and field-access APIs. Each list batches record label
** refreshes by table, preserving table permissions and record security locks.
** Next accepts the flat /app/name/ routes, including dotted process names.
*******************************************************************************/
@@ -131,6 +132,17 @@ public final class TargetResolver
/*******************************************************************************
** Persist record labels only when all label fields are FULL for this session.
*******************************************************************************/
public static String cacheableLabel(FavoriteEntry target)
{
return ("RECORD".equals(target.targetType())
&& !FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(target.targetName())) ? null : target.label());
}
/*******************************************************************************
** One QueryAction per referenced record table; no cached labels escape.
*******************************************************************************/
@@ -52,7 +52,7 @@ public final class CollaborationApi
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0")
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.1.0")
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence")
.withSpec(PostPresenceSpec.class).withSpec(DeletePresenceSpec.class).withSpec(GetPresenceSpec.class)
.withSpec(ListFavoritesSpec.class)
@@ -46,6 +46,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
@@ -112,10 +113,10 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
*******************************************************************************/
static List<BasicResponse> errors()
{
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", "CollaborationErrorV1")));
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", BasicErrorResponseV1.class.getSimpleName())));
}
/*******************************************************************************
@@ -70,7 +70,7 @@ public final class CollaborationSchemas
/*******************************************************************************
** An attachment visible through its parent record; no storage coordinates are exposed.
*******************************************************************************/
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete) implements ToSchema
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName) implements ToSchema
{
/*******************************************************************************
** Describe this versioned body for generated clients.
@@ -89,7 +89,8 @@ public final class CollaborationSchemas
Map.entry("description", new Schema().withType(Type.STRING).withDescription("Optional description.").withNullable(true)),
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("Uploader identity.").withNullable(true)),
Map.entry("createDate", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Upload time.").withNullable(true)),
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true))))));
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true)),
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Uploader display name.").withNullable(true))))));
}
}
@@ -201,7 +202,7 @@ public final class CollaborationSchemas
/*******************************************************************************
** A previous comment body.
*******************************************************************************/
public record CollaborationCommentRevisionV1(Integer id, String body, Instant editedAt, String editedByUserId) implements ToSchema
public record CollaborationCommentRevisionV1(Integer id, String body, Instant editedAt, String editedByUserId, String editedByDisplayName) implements ToSchema
{
/*******************************************************************************
** Describe this versioned body for generated clients.
@@ -213,14 +214,15 @@ public final class CollaborationSchemas
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Revision identifier.").withNullable(true)),
Map.entry("body", new Schema().withType(Type.STRING).withDescription("Previous body text.").withNullable(true)),
Map.entry("editedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Time the body was replaced.").withNullable(true)),
Map.entry("editedByUserId", new Schema().withType(Type.STRING).withDescription("Editor identity.").withNullable(true))))));
Map.entry("editedByUserId", new Schema().withType(Type.STRING).withDescription("Editor identity.").withNullable(true)),
Map.entry("editedByDisplayName", new Schema().withType(Type.STRING).withDescription("Editor display name.").withNullable(true))))));
}
}
/*******************************************************************************
** A subscription to record changes and comments.
*******************************************************************************/
public record CollaborationFollowV1(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil) implements ToSchema
public record CollaborationFollowV1(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil, String userDisplayName) implements ToSchema
{
/*******************************************************************************
** Describe this versioned body for generated clients.
@@ -234,7 +236,8 @@ public final class CollaborationSchemas
Map.entry("reason", new Schema().withType(Type.STRING).withEnumValues(List.of("MANUAL", "AUTHOR", "COMMENTED", "MENTIONED", "ASSIGNED", "SHARED")).withDescription("How following began.").withNullable(true)),
Map.entry("notifyOnChange", new Schema().withType(Type.BOOLEAN).withDescription("Receive record change digests.").withNullable(true)),
Map.entry("notifyOnComment", new Schema().withType(Type.BOOLEAN).withDescription("Receive new comment notifications.").withNullable(true)),
Map.entry("mutedUntil", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Notifications muted until this time, if set.").withNullable(true))))));
Map.entry("mutedUntil", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Notifications muted until this time, if set.").withNullable(true)),
Map.entry("userDisplayName", new Schema().withType(Type.STRING).withDescription("Follower display name.").withNullable(true))))));
}
}
@@ -511,7 +514,7 @@ public final class CollaborationSchemas
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Standard QQQ error response.").withProperties(new TreeMap<>(Map.ofEntries(
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("error", new Schema().withType(Type.STRING).withDescription("Human-readable reason the operation failed.").withNullable(true))))));
}
}
@@ -678,14 +681,14 @@ public final class CollaborationSchemas
{
Map<String, Schema> schemas = new TreeMap<>();
schemas.put("CollaborationFavoriteV1", new CollaborationFavoriteV1(null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationAttachmentV1", new CollaborationAttachmentV1(null, null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationAttachmentV1", new CollaborationAttachmentV1(null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationTagV1", new CollaborationTagV1(null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationPersonV1", new CollaborationPersonV1(null, null, null, null).toSchema());
schemas.put("CollaborationMentionV1", new CollaborationMentionV1(null, null).toSchema());
schemas.put("CollaborationMentionCandidateV1", new CollaborationMentionCandidateV1(null, null).toSchema());
schemas.put("CollaborationCommentV1", new CollaborationCommentV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationCommentRevisionV1", new CollaborationCommentRevisionV1(null, null, null, null).toSchema());
schemas.put("CollaborationFollowV1", new CollaborationFollowV1(null, null, null, null, null, null).toSchema());
schemas.put("CollaborationCommentRevisionV1", new CollaborationCommentRevisionV1(null, null, null, null, null).toSchema());
schemas.put("CollaborationFollowV1", new CollaborationFollowV1(null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationFollowStateV1", new CollaborationFollowStateV1(null, null).toSchema());
schemas.put("CollaborationTimelineDetailV1", new CollaborationTimelineDetailV1(null, null, null, null).toSchema());
schemas.put("CollaborationTimelineItemV1", new CollaborationTimelineItemV1(null, null, null, null, null, null, null, null, null).toSchema());
@@ -711,6 +714,10 @@ public final class CollaborationSchemas
schemas.put("CollaborationCommentPostRequestV1", new CollaborationCommentPostRequestV1(null, null, null).toSchema());
schemas.put("CollaborationCommentEditRequestV1", new CollaborationCommentEditRequestV1(null).toSchema());
schemas.put("CollaborationFollowRequestV1", new CollaborationFollowRequestV1(null, null).toSchema());
schemas.put("CollaborationPresenceRequestV1", new CollaborationPresenceSchemas.CollaborationPresenceRequestV1(null, null, null, null, null).toSchema());
schemas.put("CollaborationPresenceLeaveV1", new CollaborationPresenceSchemas.CollaborationPresenceLeaveV1(null, null, null).toSchema());
schemas.put("CollaborationPresenceSnapshotV1", new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(null, null, null).toSchema());
schemas.put("CollaborationPresenceViewerV1", new CollaborationPresenceSchemas.CollaborationPresenceViewerV1(null, null, null, null).toSchema());
schemas.putAll(CollaborationWorkSchemas.components());
return (schemas);
}
@@ -22,6 +22,10 @@
package com.kingsrook.qbits.collaboration.api;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationAttachmentV1;
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationCommentRevisionV1;
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationCommentV1;
@@ -40,10 +44,13 @@ import com.kingsrook.qbits.collaboration.comments.MentionCandidate;
import com.kingsrook.qbits.collaboration.comments.MentionEntry;
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
import com.kingsrook.qbits.collaboration.follow.FollowEntry;
import com.kingsrook.qbits.collaboration.people.PeopleService;
import com.kingsrook.qbits.collaboration.people.PersonEntry;
import com.kingsrook.qbits.collaboration.tags.TagEntry;
import com.kingsrook.qbits.collaboration.timeline.TimelineDetail;
import com.kingsrook.qbits.collaboration.timeline.TimelineItem;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
@@ -67,13 +74,39 @@ final class CollaborationViews
value.id(), value.targetType(), value.targetName(), value.recordId(), value.label(), value.folder(), value.sortOrder(), value.available(), value.href()));
}
/*******************************************************************************
** One display name per distinct user id, from a single directory query.
*******************************************************************************/
static Map<String, String> names(String... userIds) throws QException
{
return (new PeopleService().displayNames(Arrays.asList(userIds)));
}
/*******************************************************************************
** The display name for an id; null stays null, unknown ids show themselves.
*******************************************************************************/
private static String name(Map<String, String> names, String userId)
{
return (userId == null ? null : names.getOrDefault(userId, userId));
}
/*******************************************************************************
** Project attachment display data.
*******************************************************************************/
static CollaborationAttachmentV1 attachment(AttachmentEntry value)
static CollaborationAttachmentV1 attachment(AttachmentEntry value) throws QException
{
return (new CollaborationAttachmentV1(
value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete()));
return (attachments(List.of(value)).getFirst());
}
/*******************************************************************************
** Project attachments, resolving uploader names in one batch.
*******************************************************************************/
static List<CollaborationAttachmentV1> attachments(List<AttachmentEntry> values) throws QException
{
Map<String, String> names = names(values.stream().map(AttachmentEntry::createdByUserId).toArray(String[]::new));
return (values.stream().map(value -> new CollaborationAttachmentV1(
value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete(),
name(names, value.createdByUserId()))).toList());
}
/*******************************************************************************
@@ -115,19 +148,29 @@ final class CollaborationViews
/*******************************************************************************
** Project commentrevision display data.
*******************************************************************************/
static CollaborationCommentRevisionV1 commentRevision(CommentRevision value)
static List<CollaborationCommentRevisionV1> commentRevisions(List<CommentRevision> values) throws QException
{
return (new CollaborationCommentRevisionV1(
value.id(), value.body(), value.editedAt(), value.editedByUserId()));
Map<String, String> names = names(values.stream().map(CommentRevision::editedByUserId).toArray(String[]::new));
return (values.stream().map(value -> new CollaborationCommentRevisionV1(
value.id(), value.body(), value.editedAt(), value.editedByUserId(), name(names, value.editedByUserId()))).toList());
}
/*******************************************************************************
** Project follow display data.
*******************************************************************************/
static CollaborationFollowV1 follow(FollowEntry value)
static CollaborationFollowV1 follow(FollowEntry value) throws QException
{
return (new CollaborationFollowV1(
value.id(), value.userId(), value.reason(), value.notifyOnChange(), value.notifyOnComment(), value.mutedUntil()));
return (follows(List.of(value)).getFirst());
}
/*******************************************************************************
** Project followers, resolving names in one batch.
*******************************************************************************/
static List<CollaborationFollowV1> follows(List<FollowEntry> values) throws QException
{
Map<String, String> names = names(values.stream().map(FollowEntry::userId).toArray(String[]::new));
return (values.stream().map(value -> new CollaborationFollowV1(
value.id(), value.userId(), value.reason(), value.notifyOnChange(), value.notifyOnComment(), value.mutedUntil(), name(names, value.userId()))).toList());
}
/*******************************************************************************
@@ -159,33 +202,70 @@ final class CollaborationViews
/*******************************************************************************
** Project Share public fields.
*******************************************************************************/
static CollaborationWorkSchemas.CollaborationShareV1 share(com.kingsrook.qqq.backend.core.model.data.QRecord row)
static CollaborationWorkSchemas.CollaborationShareV1 share(QRecord row) throws QException
{
return (new CollaborationWorkSchemas.CollaborationShareV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("fromUserId"), row.getValueString("toUserId"), row.getValueString("message"), row.getValueBoolean("recipientCouldRead"), row.getValueInstant("sentAt")));
return (shares(List.of(row)).getFirst());
}
/*******************************************************************************
** Project shares, resolving sender and recipient names in one batch.
*******************************************************************************/
static List<CollaborationWorkSchemas.CollaborationShareV1> shares(List<QRecord> rows) throws QException
{
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("fromUserId"), row.getValueString("toUserId"))).toArray(String[]::new));
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationShareV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
row.getValueString("fromUserId"), row.getValueString("toUserId"), row.getValueString("message"), row.getValueBoolean("recipientCouldRead"), row.getValueInstant("sentAt"),
name(names, row.getValueString("fromUserId")), name(names, row.getValueString("toUserId")))).toList());
}
/*******************************************************************************
** Project AccessRequest public fields.
*******************************************************************************/
static CollaborationWorkSchemas.CollaborationAccessRequestV1 accessRequest(com.kingsrook.qqq.backend.core.model.data.QRecord row)
static CollaborationWorkSchemas.CollaborationAccessRequestV1 accessRequest(QRecord row) throws QException
{
return (new CollaborationWorkSchemas.CollaborationAccessRequestV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("requesterUserId"), row.getValueString("message"), row.getValueString("status"), row.getValueInstant("handledAt"), row.getValueString("note")));
return (accessRequests(List.of(row)).getFirst());
}
/*******************************************************************************
** Project access requests, resolving requester and handler names in one batch.
*******************************************************************************/
static List<CollaborationWorkSchemas.CollaborationAccessRequestV1> accessRequests(List<QRecord> rows) throws QException
{
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("requesterUserId"), row.getValueString("handledByUserId"))).toArray(String[]::new));
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationAccessRequestV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
row.getValueString("requesterUserId"), row.getValueString("message"), row.getValueString("status"), row.getValueInstant("handledAt"), row.getValueString("note"),
name(names, row.getValueString("requesterUserId")), name(names, row.getValueString("handledByUserId")))).toList());
}
/*******************************************************************************
** Project Task public fields.
*******************************************************************************/
static CollaborationWorkSchemas.CollaborationTaskV1 task(com.kingsrook.qqq.backend.core.model.data.QRecord row)
static CollaborationWorkSchemas.CollaborationTaskV1 task(QRecord row) throws QException
{
return (new CollaborationWorkSchemas.CollaborationTaskV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("title"), row.getValueString("notes"), row.getValueString("assigneeUserId"), row.getValueInstant("dueAt"), row.getValueInstant("remindAt"), row.getValueString("status"), row.getValueString("createdByUserId"), row.getValueInstant("completedAt"), row.getValueString("completedByUserId")));
return (tasks(List.of(row)).getFirst());
}
/*******************************************************************************
** Project tasks, resolving assignee, creator and completer names in one batch.
*******************************************************************************/
static List<CollaborationWorkSchemas.CollaborationTaskV1> tasks(List<QRecord> rows) throws QException
{
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("assigneeUserId"), row.getValueString("createdByUserId"),
row.getValueString("completedByUserId"))).toArray(String[]::new));
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationTaskV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
row.getValueString("title"), row.getValueString("notes"), row.getValueString("assigneeUserId"), row.getValueInstant("dueAt"), row.getValueInstant("remindAt"), row.getValueString("status"),
row.getValueString("createdByUserId"), row.getValueInstant("completedAt"), row.getValueString("completedByUserId"),
name(names, row.getValueString("assigneeUserId")), name(names, row.getValueString("createdByUserId")), name(names, row.getValueString("completedByUserId")))).toList());
}
/*******************************************************************************
** Project Assignment public fields.
*******************************************************************************/
static CollaborationWorkSchemas.CollaborationAssignmentV1 assignment(com.kingsrook.qqq.backend.core.model.data.QRecord row)
static CollaborationWorkSchemas.CollaborationAssignmentV1 assignment(QRecord row) throws QException
{
return (new CollaborationWorkSchemas.CollaborationAssignmentV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("ownerUserId"), row.getValueString("assigneeUserId")));
Map<String, String> names = names(row.getValueString("ownerUserId"), row.getValueString("assigneeUserId"));
return (new CollaborationWorkSchemas.CollaborationAssignmentV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
row.getValueString("ownerUserId"), row.getValueString("assigneeUserId"), name(names, row.getValueString("ownerUserId")), name(names, row.getValueString("assigneeUserId"))));
}
}
@@ -95,7 +95,7 @@ public final class CollaborationWorkSchemas
/*******************************************************************************
** Versioned Share body.
*******************************************************************************/
public record CollaborationShareV1(Integer id, String tableName, String recordId, String fromUserId, String toUserId, String message, Boolean recipientCouldRead, Instant sentAt) implements ToSchema
public record CollaborationShareV1(Integer id, String tableName, String recordId, String fromUserId, String toUserId, String message, Boolean recipientCouldRead, Instant sentAt, String fromDisplayName, String toDisplayName) implements ToSchema
{
/*******************************************************************************
** Explicit schema for generated clients.
@@ -111,7 +111,9 @@ public final class CollaborationWorkSchemas
Map.entry("toUserId", new Schema().withType(Type.STRING).withDescription("toUserId.").withNullable(true)),
Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)),
Map.entry("recipientCouldRead", new Schema().withType(Type.BOOLEAN).withDescription("recipientCouldRead.").withNullable(true)),
Map.entry("sentAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("sentAt.").withNullable(true))))));
Map.entry("sentAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("sentAt.").withNullable(true)),
Map.entry("fromDisplayName", new Schema().withType(Type.STRING).withDescription("Sender display name.").withNullable(true)),
Map.entry("toDisplayName", new Schema().withType(Type.STRING).withDescription("Recipient display name.").withNullable(true))))));
}
}
@@ -151,7 +153,7 @@ public final class CollaborationWorkSchemas
/*******************************************************************************
** Versioned AccessRequest body.
*******************************************************************************/
public record CollaborationAccessRequestV1(Integer id, String tableName, String recordId, String requesterUserId, String message, String status, Instant handledAt, String note) implements ToSchema
public record CollaborationAccessRequestV1(Integer id, String tableName, String recordId, String requesterUserId, String message, String status, Instant handledAt, String note, String requesterDisplayName, String handledByDisplayName) implements ToSchema
{
/*******************************************************************************
** Explicit schema for generated clients.
@@ -167,7 +169,9 @@ public final class CollaborationWorkSchemas
Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)),
Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true)),
Map.entry("handledAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("handledAt.").withNullable(true)),
Map.entry("note", new Schema().withType(Type.STRING).withDescription("note.").withNullable(true))))));
Map.entry("note", new Schema().withType(Type.STRING).withDescription("note.").withNullable(true)),
Map.entry("requesterDisplayName", new Schema().withType(Type.STRING).withDescription("Requester display name.").withNullable(true)),
Map.entry("handledByDisplayName", new Schema().withType(Type.STRING).withDescription("Display name of the person who handled the request.").withNullable(true))))));
}
}
@@ -210,7 +214,7 @@ public final class CollaborationWorkSchemas
/*******************************************************************************
** Versioned Task body.
*******************************************************************************/
public record CollaborationTaskV1(Integer id, String tableName, String recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt, String status, String createdByUserId, Instant completedAt, String completedByUserId) implements ToSchema
public record CollaborationTaskV1(Integer id, String tableName, String recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt, String status, String createdByUserId, Instant completedAt, String completedByUserId, String assigneeDisplayName, String createdByDisplayName, String completedByDisplayName) implements ToSchema
{
/*******************************************************************************
** Explicit schema for generated clients.
@@ -230,7 +234,10 @@ public final class CollaborationWorkSchemas
Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true)),
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("createdByUserId.").withNullable(true)),
Map.entry("completedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("completedAt.").withNullable(true)),
Map.entry("completedByUserId", new Schema().withType(Type.STRING).withDescription("completedByUserId.").withNullable(true))))));
Map.entry("completedByUserId", new Schema().withType(Type.STRING).withDescription("completedByUserId.").withNullable(true)),
Map.entry("assigneeDisplayName", new Schema().withType(Type.STRING).withDescription("Assignee display name.").withNullable(true)),
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Creator display name.").withNullable(true)),
Map.entry("completedByDisplayName", new Schema().withType(Type.STRING).withDescription("Display name of the person who completed the task.").withNullable(true))))));
}
}
@@ -312,7 +319,7 @@ public final class CollaborationWorkSchemas
/*******************************************************************************
** Versioned Assignment body.
*******************************************************************************/
public record CollaborationAssignmentV1(Integer id, String tableName, String recordId, String ownerUserId, String assigneeUserId) implements ToSchema
public record CollaborationAssignmentV1(Integer id, String tableName, String recordId, String ownerUserId, String assigneeUserId, String ownerDisplayName, String assigneeDisplayName) implements ToSchema
{
/*******************************************************************************
** Explicit schema for generated clients.
@@ -325,7 +332,9 @@ public final class CollaborationWorkSchemas
Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("tableName.").withNullable(true)),
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("recordId.").withNullable(true)),
Map.entry("ownerUserId", new Schema().withType(Type.STRING).withDescription("ownerUserId.").withNullable(true)),
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true))))));
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true)),
Map.entry("ownerDisplayName", new Schema().withType(Type.STRING).withDescription("Owner display name.").withNullable(true)),
Map.entry("assigneeDisplayName", new Schema().withType(Type.STRING).withDescription("Assignee display name.").withNullable(true))))));
}
}
@@ -357,18 +366,18 @@ public final class CollaborationWorkSchemas
schemas.put("CollaborationLinkV1", new CollaborationLinkV1(null, null).toSchema());
schemas.put("CollaborationShareRecipientV1", new CollaborationShareRecipientV1(null, null, null).toSchema());
schemas.put("CollaborationSharePreviewV1", new CollaborationSharePreviewV1(null).toSchema());
schemas.put("CollaborationShareV1", new CollaborationShareV1(null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationShareV1", new CollaborationShareV1(null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationShareListV1", new CollaborationShareListV1(null).toSchema());
schemas.put("CollaborationShareRequestV1", new CollaborationShareRequestV1(null, null).toSchema());
schemas.put("CollaborationAccessRequestV1", new CollaborationAccessRequestV1(null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationAccessRequestV1", new CollaborationAccessRequestV1(null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationAccessRequestCreateRequestV1", new CollaborationAccessRequestCreateRequestV1(null, null, null, null).toSchema());
schemas.put("CollaborationAccessRequestListV1", new CollaborationAccessRequestListV1(null, null).toSchema());
schemas.put("CollaborationTaskV1", new CollaborationTaskV1(null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationTaskV1", new CollaborationTaskV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationTaskCreateRequestV1", new CollaborationTaskCreateRequestV1(null, null, null, null, null).toSchema());
schemas.put("CollaborationTaskPatchRequestV1", new CollaborationTaskPatchRequestV1(null, null, null, null, null, null).toSchema());
schemas.put("CollaborationTaskListV1", new CollaborationTaskListV1(null, null).toSchema());
schemas.put("CollaborationAssignmentRequestV1", new CollaborationAssignmentRequestV1(null, null).toSchema());
schemas.put("CollaborationAssignmentV1", new CollaborationAssignmentV1(null, null, null, null, null).toSchema());
schemas.put("CollaborationAssignmentV1", new CollaborationAssignmentV1(null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationMyWorkV1", new CollaborationMyWorkV1(null, null, null, null).toSchema());
return (schemas);
}
@@ -69,6 +69,6 @@ public class CommentHistorySpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var page = page(input, new CommentsService().history(id(input)));
return (new CollaborationCommentHistoryV1(page.items().stream().map(CollaborationViews::commentRevision).toList(), page.nextCursor()));
return (new CollaborationCommentHistoryV1(CollaborationViews.commentRevisions(page.items()), page.nextCursor()));
}
}
@@ -75,8 +75,8 @@ public class CreateSharesSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationWorkSchemas.CollaborationShareRequestV1.class);
return (new CollaborationWorkSchemas.CollaborationShareListV1(new com.kingsrook.qbits.collaboration.sharing.SharingService()
.share(table(input), pk(input), recipients(body.toUserIds()), body.message()).stream().map(CollaborationViews::share).toList()));
return (new CollaborationWorkSchemas.CollaborationShareListV1(CollaborationViews.shares(new com.kingsrook.qbits.collaboration.sharing.SharingService()
.share(table(input), pk(input), recipients(body.toUserIds()), body.message()))));
}
}
@@ -69,6 +69,6 @@ public class GetFollowSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var state = new FollowService().state(table(input), pk(input));
return (new CollaborationFollowStateV1(state.isPresent(), state.map(CollaborationViews::follow).orElse(null)));
return (new CollaborationFollowStateV1(state.isPresent(), state.isPresent() ? CollaborationViews.follow(state.get()) : null));
}
}
@@ -65,7 +65,7 @@ public class GetMyWorkSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var summary = new com.kingsrook.qbits.collaboration.mywork.MyWorkService().summary();
return (new CollaborationWorkSchemas.CollaborationMyWorkV1(summary.openTaskCount(), summary.tasks().stream().map(CollaborationViews::task).toList(),
return (new CollaborationWorkSchemas.CollaborationMyWorkV1(summary.openTaskCount(), CollaborationViews.tasks(summary.tasks()),
summary.assignedRecordsByTable(), summary.unreadMentionCount()));
}
@@ -69,6 +69,6 @@ public class ListAttachmentsSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var page = page(input, new AttachmentsService().list(table(input), pk(input)));
return (new CollaborationAttachmentListV1(page.items().stream().map(CollaborationViews::attachment).toList(), page.nextCursor()));
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), page.nextCursor()));
}
}
@@ -69,6 +69,6 @@ public class ListFollowersSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var page = page(input, new FollowService().followers(table(input), pk(input)));
return (new CollaborationFollowerListV1(page.items().stream().map(CollaborationViews::follow).toList(), page.nextCursor()));
return (new CollaborationFollowerListV1(CollaborationViews.follows(page.items()), page.nextCursor()));
}
}
@@ -66,7 +66,7 @@ public class ListMyAccessRequestsSpec extends CollaborationJsonSpec
{
var values = new com.kingsrook.qbits.collaboration.sharing.AccessRequestService().mine();
var page = page(input, values);
return (new CollaborationWorkSchemas.CollaborationAccessRequestListV1(page.items().stream().map(CollaborationViews::accessRequest).toList(), page.nextCursor()));
return (new CollaborationWorkSchemas.CollaborationAccessRequestListV1(CollaborationViews.accessRequests(page.items()), page.nextCursor()));
}
}
@@ -65,7 +65,7 @@ public class ListMyTasksSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var page = new com.kingsrook.qbits.collaboration.tasks.TasksService().mine(input.getQueryParam("status"), limit(input), input.getQueryParam("cursor"));
return (new CollaborationWorkSchemas.CollaborationTaskListV1(page.tasks().stream().map(CollaborationViews::task).toList(), page.nextCursor()));
return (new CollaborationWorkSchemas.CollaborationTaskListV1(CollaborationViews.tasks(page.tasks()), page.nextCursor()));
}
}
@@ -70,7 +70,7 @@ public class ListTasksSpec extends CollaborationJsonSpec
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("includeDone must be true or false"));
}
var page = page(input, new com.kingsrook.qbits.collaboration.tasks.TasksService().list(table(input), pk(input), Boolean.valueOf(includeDone)));
return (new CollaborationWorkSchemas.CollaborationTaskListV1(page.items().stream().map(CollaborationViews::task).toList(), page.nextCursor()));
return (new CollaborationWorkSchemas.CollaborationTaskListV1(CollaborationViews.tasks(page.items()), page.nextCursor()));
}
}
@@ -24,15 +24,22 @@ package com.kingsrook.qbits.collaboration.app;
import java.time.Instant;
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.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
@@ -41,27 +48,45 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOp
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData.Statistic;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** The Collaboration app and its home. Counts use QQQ actions with the current
** QContext session, preserving permissions and record security.
** The Collaboration app and its home, built from standard QQQ widgets: a KPI
** block and three full-width tables (tags, custom fields, who's here). Counts
** use QQQ actions with the current QContext session, so permissions and record
** security apply, and every block carries a real empty-state message. No colors
** are specified, so the frontend theme (including dark mode) decides.
*******************************************************************************/
public final class CollaborationHome
{
public static final String HOME_WIDGET = "collaborationHome";
public static final String SETUP_WIDGET = "collaborationSetup";
public static final String KPI_WIDGET = "collaborationKpis";
public static final String TAGS_WIDGET = "collaborationTags";
public static final String FIELDS_WIDGET = "collaborationCustomFields";
public static final String PRESENCE_WIDGET = "collaborationWhosHere";
public static final List<String> HOME_WIDGETS = List.of(SETUP_WIDGET, KPI_WIDGET, TAGS_WIDGET, FIELDS_WIDGET, PRESENCE_WIDGET);
public static final String NOBODY_HERE = "Nobody else is here right now.";
public static final String NO_TAGS = "No tags yet. Tags appear here once someone creates one on a record.";
public static final String NO_FIELDS = "No custom fields yet. Define one under Custom fields.";
public static final String PRESENCE_ADMIN_ONLY = "Who's here is visible to collaboration administrators.";
@@ -81,7 +106,7 @@ public final class CollaborationHome
{
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel("Collaboration").withIcon(new QIcon("forum"))
.withPermissionRules(CollaborationPermissions.permissionRules())
.withWidgets(List.of(HOME_WIDGET))
.withWidgets(HOME_WIDGETS)
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
app.setParentAppName(config.getParentAppName());
@@ -91,141 +116,263 @@ public final class CollaborationHome
/*******************************************************************************
** Full-width HTML widget, following the reference Storage home renderer.
** The four full-width home widgets, in display order.
*******************************************************************************/
public static QWidgetMetaData widget()
public static List<QWidgetMetaData> widgets()
{
return (new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false)
return (List.of(
widget(SETUP_WIDGET, "Setup", WidgetType.TABLE, SetupRenderer.class),
widget(KPI_WIDGET, "Overview", WidgetType.MULTI_STATISTICS, KpiRenderer.class),
widget(TAGS_WIDGET, "Tags", WidgetType.TABLE, TagsRenderer.class),
widget(FIELDS_WIDGET, "Custom fields", WidgetType.TABLE, FieldsRenderer.class),
widget(PRESENCE_WIDGET, "Who's here", WidgetType.TABLE, PresenceRenderer.class)));
}
/*******************************************************************************
** One full-width card-less widget guarded by the shared permission rules.
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer)
{
return (new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType())
.withCodeReference(new QCodeReference(renderer)).withGridColumns(12).withIsCard(false)
.withPermissionRules(CollaborationPermissions.permissionRules()));
}
/*******************************************************************************
** Show persisted enabled overrides by scope type, and recent directory use.
** These are settings counts, not a claim about resolved host-table features.
** The Setup card: each record feature, whether it is on, for which tables,
** where that comes from, and the next step.
*******************************************************************************/
public static String render(Instant now) throws QException
public static TableData setup() throws QException
{
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
StringBuilder html = new StringBuilder("<div style=\"display:flex;flex-direction:column;gap:1rem;width:100%;overflow-wrap:anywhere;\"><section><h3>Feature settings</h3>");
long settings = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter());
if(settings == 0)
List<Map<String, Object>> rows = new ArrayList<>();
for(CollaborationSetupStatus.Item item : CollaborationSetupStatus.load(QContext.getQInstance()))
{
html.append("<p>No feature settings yet: features follow the code defaults</p>");
Map<String, Object> row = new LinkedHashMap<>();
row.put("feature", item.feature());
row.put("state", item.state());
row.put("where", item.where());
row.put("source", item.source());
row.put("next", item.nextStep() == null ? "" : item.nextStep());
rows.add(row);
}
else
{
html.append("<p>Enabled feature settings per scope:</p><ul>");
for(String scope : List.of("INSTANCE", "APP", "TABLE"))
{
long enabled = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter()
.withCriteria(new QFilterCriteria("scopeType", QCriteriaOperator.EQUALS, scope))
.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)));
html.append("<li>").append(scope).append(": ").append(enabled).append("</li>");
}
html.append("</ul><p>Feature settings take effect after saving.</p>");
}
long people = count(CollaborationTables.USER, new QQueryFilter());
long recent = count(CollaborationTables.USER, new QQueryFilter()
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS)))
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)));
html.append("</section><section><h3>People</h3><p>People known: ").append(people)
.append("</p><p>Active in the last 7 days: ").append(recent).append("</p>");
if(people == 0)
{
html.append("<p>No people yet: the directory will be filled by collaboration calls when the feature services are installed.</p>");
}
html.append("</section>");
personalSummary(html, CollaborationTables.FAVORITE, "Favorites");
personalSummary(html, CollaborationTables.RECENT, "Recently viewed");
attachmentSummary(html);
tagSummary(html);
fieldSummary(html);
html.append("<section><h3>Open access requests</h3><p>").append(count(CollaborationTables.ACCESS_REQUEST,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
html.append("<section><h3>Open tasks</h3><p>").append(count(CollaborationTables.TASK,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
html.append("<section><h3>Overdue tasks</h3><p>").append(count(CollaborationTables.TASK,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN").withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now))).append("</p></section>");
html.append("<section><h3>Shares this week</h3><p>").append(count(CollaborationTables.SHARE,
new QQueryFilter().withCriteria("sentAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
.withCriteria("sentAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))).append("</p></section>");
html.append("<section><h3>Comments this week</h3><p>").append(count(CollaborationTables.COMMENT, new QQueryFilter()
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
.withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("</p></section>");
html.append("<section><h3>Open threads</h3><p>").append(count(CollaborationTables.COMMENT, new QQueryFilter()
.withCriteria("threadId", QCriteriaOperator.IS_BLANK).withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK)
.withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("</p></section>");
if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
{
html.append("<section><h3>Followers</h3><p>").append(count(CollaborationTables.FOLLOW, new QQueryFilter())).append("</p></section>");
}
html.append("<p>Conversation totals are approximate where parent record locks require post-filtering.</p>");
return (html.append("</div>").toString());
return (table("Setup", "Nothing to set up.", rows, column("Feature", "feature", "left"), column("State", "state", "left"), column("Where", "where", "left"),
column("From", "source", "left"), column("Next step", "next", "left")));
}
/*******************************************************************************
** Caller-filtered vocabulary totals and five most used active tags.
** The KPI groups: conversations, work, files and custom fields.
*******************************************************************************/
private static void tagSummary(StringBuilder html) throws QException
public static MultiStatisticsData kpis(Instant now) throws QException
{
QQueryFilter active = new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK);
html.append("<section><h3>Tags</h3><p>Vocabulary size: ").append(count(CollaborationTables.TAG, active))
.append("</p><p>Pending suggestions: ").append(count(CollaborationTables.TAG, new QQueryFilter()
.withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK).withCriteria("approved", QCriteriaOperator.EQUALS, false)))
.append("</p><ul>");
com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput input = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(CollaborationTables.TAG)
.withFilter(active.withCriteria("approved", QCriteriaOperator.EQUALS, true).withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(5));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
for(com.kingsrook.qqq.backend.core.model.data.QRecord tag : new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(input).getRecords())
{
html.append("<li>").append(tag.getValueString("label").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
.append(": ").append(tag.getValueInteger("usageCount")).append("</li>");
}
html.append("</ul></section>");
}
/*******************************************************************************
** Real table aggregates, restricted to readable parent tables. Parent locks
** are post-filtered on rows, so these aggregate totals are approximate.
*******************************************************************************/
private static void attachmentSummary(StringBuilder html) throws QException
{
Aggregate count = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
Instant weekAgo = now.minus(7, ChronoUnit.DAYS);
int commentsThisWeek = count(CollaborationTables.COMMENT, new QQueryFilter()
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, weekAgo)
.withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK));
int openThreads = count(CollaborationTables.COMMENT, new QQueryFilter().withCriteria("threadId", QCriteriaOperator.IS_BLANK)
.withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK));
int openTasks = count(CollaborationTables.TASK, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"));
int overdue = count(CollaborationTables.TASK, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN")
.withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now));
int openRequests = count(CollaborationTables.ACCESS_REQUEST, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"));
int activeFields = count(CollaborationTables.FIELD_DEFINITION, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
Aggregate files = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
Aggregate bytes = new Aggregate().withFieldName("sizeBytes").withOperator(AggregateOperator.SUM);
AggregateInput input = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(count, bytes));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<AggregateResult> results = new AggregateAction().execute(input).getResults();
Object rowCount = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(count);
Object totalBytes = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(bytes);
html.append("<section><h3>Attachments</h3><p>Attachments: ").append(rowCount)
.append("</p><p>Total size (bytes): ").append(totalBytes == null ? 0 : totalBytes)
.append("</p><p>Approximate totals: parent record locks are applied when browsing attachments.</p>");
if("0".equals(String.valueOf(rowCount)))
{
html.append("<p>No attachments yet.</p>");
}
html.append("</section>");
AggregateInput aggregateInput = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(files, bytes));
aggregateInput.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<AggregateResult> results = new AggregateAction().execute(aggregateInput).getResults();
int attachments = results.isEmpty() ? 0 : numberOf(results.getFirst().getAggregateValue(files)).intValue();
long totalBytes = results.isEmpty() ? 0 : numberOf(results.getFirst().getAggregateValue(bytes)).longValue();
String fileSubheader = attachments == 0 ? "No attachments yet" : humanSize(totalBytes) + " in total";
return (new MultiStatisticsData("Collaboration", List.of(
new StatisticsGroupData("forum", null, "Conversations", "Last 7 days and open", List.of(
new Statistic("Comments this week", commentsThisWeek, path(CollaborationTables.COMMENT)),
new Statistic("Open threads", openThreads, path(CollaborationTables.COMMENT)))),
new StatisticsGroupData("task_alt", null, "Work", openTasks == 0 && openRequests == 0 ? "Nothing waiting" : "Waiting on someone", List.of(
new Statistic("Open tasks", openTasks, path(CollaborationTables.TASK)),
new Statistic("Overdue tasks", overdue, path(CollaborationTables.TASK)),
new Statistic("Open access requests", openRequests, path(CollaborationTables.ACCESS_REQUEST)))),
new StatisticsGroupData("attach_file", null, "Attachments", fileSubheader, List.of(
new Statistic("Attachments", attachments, path(CollaborationTables.ATTACHMENT)))),
new StatisticsGroupData("tune", null, "Custom fields", activeFields == 0 ? "None active yet" : "Defined by administrators", List.of(
new Statistic("Active custom fields", activeFields, path(CollaborationTables.FIELD_DEFINITION)))))));
}
/*******************************************************************************
** Aggregate rows and distinct owners without loading high-volume personal data.
** Approved and pending tags, most used first: label, table label, records.
*******************************************************************************/
private static void personalSummary(StringBuilder html, String table, String label) throws QException
public static TableData tags() throws QException
{
Aggregate rows = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
Aggregate users = new Aggregate().withFieldName("userId").withOperator(AggregateOperator.COUNT_DISTINCT);
AggregateInput input = new AggregateInput(table).withAggregates(List.of(rows, users));
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
QueryInput input = new QueryInput(CollaborationTables.TAG).withFilter(new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK)
.withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(200));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<AggregateResult> results = new AggregateAction().execute(input).getResults();
html.append("<section><h3>").append(label).append("</h3><p>Rows: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(rows))
.append("</p><p>Distinct users: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(users)).append("</p></section>");
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord tag : new QueryAction().execute(input).getRecords())
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("label", tag.getValueString("label"));
row.put("table", tableLabel(tag.getValueString("tableName")));
row.put("records", tag.getValueInteger("usageCount") == null ? 0 : tag.getValueInteger("usageCount"));
rows.add(row);
}
return (table("Tags", NO_TAGS, rows, column("Label", "label", "left"), column("Table", "table", "left"), column("Records", "records", "right")));
}
/*******************************************************************************
** Custom field definitions: name, table, status and index state.
*******************************************************************************/
public static TableData fields() throws QException
{
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
QueryInput input = new QueryInput(CollaborationTables.FIELD_DEFINITION).withFilter(new QQueryFilter()
.withOrderBy(new QFilterOrderBy("tableName")).withOrderBy(new QFilterOrderBy("name")).withLimit(200));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord definition : new QueryAction().execute(input).getRecords())
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", definition.getValueString("name"));
row.put("table", tableLabel(definition.getValueString("tableName")));
row.put("status", friendly(definition.getValueString("status")));
row.put("indexState", friendly(definition.getValueString("indexState")));
rows.add(row);
}
return (table("Custom fields", NO_FIELDS, rows, column("Name", "name", "left"), column("Table", "table", "left"),
column("Status", "status", "left"), column("Index state", "indexState", "left")));
}
/*******************************************************************************
** Live viewer counts per table. Only administrators see them (they cover
** records the viewer may not be able to open); others get an explanation.
*******************************************************************************/
public static TableData presence() throws QException
{
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
boolean admin = QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN);
List<Map<String, Object>> rows = new ArrayList<>();
if(admin)
{
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("table", tableLabel(table));
row.put("viewers", viewers);
rows.add(row);
});
}
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right")));
}
/*******************************************************************************
** Assemble a table widget whose empty state is a sentence, not a blank grid.
*******************************************************************************/
private static TableData table(String label, String emptyMessage, List<Map<String, Object>> rows, TableData.Column... columns)
{
return (new TableData(label, List.of(columns), rows).withNoRowsFoundHTML(emptyMessage).withRowsPerPage(10));
}
/*******************************************************************************
** A plain column; the frontend styles it.
*******************************************************************************/
private static TableData.Column column(String header, String accessor, String align)
{
//////////////////////////////////////////////////////////////////
// no width: frontends size the columns to fit narrow (390 px) cards //
//////////////////////////////////////////////////////////////////
return (new TableData.Column("default", header, accessor, null, align));
}
/*******************************************************************************
** A table's display label, "All tables" when there is none (global).
*******************************************************************************/
private static String tableLabel(String name)
{
if(name == null || name.isBlank())
{
return ("All tables");
}
QTableMetaData table = QContext.getQInstance().getTable(name);
return (table == null || table.getLabel() == null ? name : table.getLabel());
}
/*******************************************************************************
** ACTIVE becomes Active; a missing value becomes a dash.
*******************************************************************************/
private static String friendly(String value)
{
if(value == null || value.isBlank())
{
return ("-");
}
String lower = value.toLowerCase(Locale.ROOT).replace('_', ' ');
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
}
/*******************************************************************************
** Binary sizes with one decimal, like 4.7 KB; whole bytes below 1 KB.
*******************************************************************************/
public static String humanSize(long bytes)
{
if(bytes < 1024)
{
return (bytes + " B");
}
String[] units = { "KB", "MB", "GB", "TB", "PB" };
double value = bytes;
int unit = -1;
while(value >= 1024 && unit < units.length - 1)
{
value /= 1024;
unit++;
}
return (String.format(Locale.ROOT, "%.1f %s", value, units[unit]));
}
/*******************************************************************************
** Aggregate values come back as any numeric type, or null for empty sets.
*******************************************************************************/
private static Number numberOf(Object value)
{
return (value instanceof Number number ? number : 0);
}
/*******************************************************************************
** The path of a Collaboration table page, under the parent app when configured.
*******************************************************************************/
private static String path(String table)
{
CollaborationQBitConfig config = CollaborationRecords.config();
String prefix = config.getParentAppName() == null ? "/" + config.getAppName() : "/" + config.getParentAppName() + "/" + config.getAppName();
return (prefix + "/" + table);
}
@@ -233,7 +380,7 @@ public final class CollaborationHome
/*******************************************************************************
** Count without materializing rows or substituting a privileged session.
*******************************************************************************/
private static long count(String table, QQueryFilter filter) throws QException
private static int count(String table, QQueryFilter filter) throws QException
{
CountInput input = new CountInput(table).withFilter(filter);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
@@ -243,38 +390,26 @@ public final class CollaborationHome
/*******************************************************************************
** Real definition status and index totals, plus ephemeral admin-only counts.
** Setup renderer instantiated by QQQ.
*******************************************************************************/
private static void fieldSummary(StringBuilder html) throws QException
public static class SetupRenderer extends AbstractWidgetRenderer
{
html.append("<section><h3>Custom fields</h3><ul>");
for(String status : List.of("DRAFT", "ACTIVE", "ARCHIVED"))
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
html.append("<li>").append(status).append(": ").append(count(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, status))).append("</li>");
}
for(String state : List.of("NONE", "BUILDING", "READY", "FAILED"))
{
html.append("<li>Index ").append(state).append(": ").append(count(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("indexState", QCriteriaOperator.EQUALS, state))).append("</li>");
}
html.append("</ul><p>Definitions with storage or index messages: ").append(count(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("statusMessage", QCriteriaOperator.IS_NOT_BLANK))).append(". See Custom Fields for details.</p></section>");
if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
{
html.append("<section><h3>Who's here</h3><ul>");
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, count) ->
html.append("<li>").append(table.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")).append(": ").append(count).append("</li>"));
html.append("</ul></section>");
return (new RenderWidgetOutput(setup()));
}
}
/*******************************************************************************
** Widget renderer instantiated by QQQ.
** KPI renderer instantiated by QQQ.
*******************************************************************************/
public static class HomeRenderer extends AbstractWidgetRenderer
public static class KpiRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session and time.
@@ -282,7 +417,58 @@ public final class CollaborationHome
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(new RawHTML(null, CollaborationHome.render(Instant.now()))));
return (new RenderWidgetOutput(kpis(Instant.now())));
}
}
/*******************************************************************************
** Tags renderer instantiated by QQQ.
*******************************************************************************/
public static class TagsRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(tags()));
}
}
/*******************************************************************************
** Custom fields renderer instantiated by QQQ.
*******************************************************************************/
public static class FieldsRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(fields()));
}
}
/*******************************************************************************
** Presence renderer instantiated by QQQ.
*******************************************************************************/
public static class PresenceRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(presence()));
}
}
}
@@ -0,0 +1,129 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.app;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
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.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** The Setup card on the Collaboration home: which record features are on, for
** which tables, and whether that comes from Feature Settings rows or from the
** code defaults. Uses the same resolution the runtime uses
** (RecordFeaturesResolver: code declarations, then the committed rows), so the
** card never reports something the runtime would not do.
*******************************************************************************/
public final class CollaborationSetupStatus
{
private static final int NAMED_TABLES = 5;
/*******************************************************************************
** One line of the card.
*******************************************************************************/
public record Item(String feature, String state, String where, String source, String nextStep)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CollaborationSetupStatus()
{
}
/*******************************************************************************
** One line for Feature Settings itself, then one per record feature.
*******************************************************************************/
public static List<Item> load(QInstance instance) throws QException
{
List<QRecord> rows = CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter().withLimit(1000));
Set<String> featuresWithRows = new HashSet<>();
rows.forEach(row -> featuresWithRows.add(row.getValueString("feature")));
List<Item> items = new ArrayList<>();
items.add(rows.isEmpty()
? new Item("Feature settings", "Using code defaults", "-", "Configuration", "Run Create rows from configuration on Feature Settings to see and edit every setting.")
: new Item("Feature settings", "Set up", rows.size() + " row" + (rows.size() == 1 ? "" : "s"), "Feature Settings", null));
List<QTableMetaData> hostTables = CollectionUtils.nonNullMap(instance.getTables()).values().stream()
.filter(table -> !table.getName().startsWith("collaboration_") && !Boolean.TRUE.equals(table.getIsHidden()) && table.getPrimaryKeyField() != null
&& (table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_GET) || table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_QUERY)))
.sorted((a, b) -> a.getName().compareTo(b.getName())).toList();
for(RecordFeature feature : RecordFeature.values())
{
List<String> on = new ArrayList<>();
for(QTableMetaData table : hostTables)
{
if(RecordFeaturesResolver.resolve(instance, table).getFeatures().get(feature).getEnabled())
{
on.add(table.getLabel() == null ? table.getName() : table.getLabel());
}
}
String source = featuresWithRows.contains(feature.name()) ? "Feature Settings" : "Configuration";
String name = friendly(feature);
if(on.isEmpty())
{
items.add(new Item(name, "Off", "No table", source, "Add a Feature Settings row (scope INSTANCE, or TABLE for one table) and turn it on."));
}
else
{
boolean all = on.size() == hostTables.size();
String where = all ? "Every table" : String.join(", ", on.subList(0, Math.min(NAMED_TABLES, on.size()))) + (on.size() > NAMED_TABLES ? " and " + (on.size() - NAMED_TABLES) + " more" : "");
items.add(new Item(name, all ? "On" : "On for " + on.size() + " of " + hostTables.size() + " tables", where, source, null));
}
}
return (items);
}
/*******************************************************************************
** TAGS becomes Tags, CUSTOM_FIELDS becomes Custom fields.
*******************************************************************************/
static String friendly(RecordFeature feature)
{
String lower = feature.name().toLowerCase(Locale.ROOT).replace('_', ' ');
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
}
}
@@ -79,7 +79,7 @@ public class AssignmentService
if(!assigneeUserId.equals(PersonalRecords.currentUserId()))
{
Notifications.send(new CollaborationNotification(assigneeUserId, "A record was assigned to you", null,
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)));
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)).withEvent(row.getValue("id"), PersonalRecords.currentUserId()));
}
}
return (row);
@@ -45,14 +45,13 @@ public class AttachmentStorage
}
/*******************************************************************************
** The bucket's own upload cap, when object storage exposes it through its api
** package. It does not yet (requested from qbit-object-storage), so this
** returns Long.MAX_VALUE: collaboration enforces its configured per-table cap,
** and object storage still enforces the bucket's cap when the object is put.
** The bucket's own upload cap, from object storage's api package; Long.MAX_VALUE
** when the bucket has no limit. Collaboration also enforces its per-table cap.
*******************************************************************************/
public long maxBytes(String name)
{
return (Long.MAX_VALUE);
Long max = bucket(name).maxUploadBytes();
return (max == null ? Long.MAX_VALUE : max);
}
/*******************************************************************************
@@ -92,7 +92,7 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final AttachmentsService service;
private AttachmentsService service;
private final ProcessSummaryLine selected = line(Status.OK, "will be deleted", "deleted");
private final ProcessSummaryLine skipped = line(Status.WARNING, "cannot be deleted (skipped)", "skipped");
@@ -101,7 +101,11 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public Review()
{
this(new AttachmentsService());
//////////////////////////////////////////////////////////////////////////
// no QContext here: QQQ constructs steps during context-free instance //
// validation, so the service is created on first use (in a run) //
//////////////////////////////////////////////////////////////////////////
this(null);
}
/*******************************************************************************
@@ -112,6 +116,20 @@ public final class DeleteAttachmentsProcess
this.service = service;
}
/*******************************************************************************
** The attachments service, created on first use inside a run (QContext exists).
*******************************************************************************/
private AttachmentsService service()
{
if(service == null)
{
service = new AttachmentsService();
}
return (service);
}
/*******************************************************************************
** Dry run only; no object or row mutations.
*******************************************************************************/
@@ -122,7 +140,7 @@ public final class DeleteAttachmentsProcess
{
try
{
QRecord checked = service.requireMutable(row.getValue("id"));
QRecord checked = service().requireMutable(row.getValue("id"));
output.getRecords().add(checked);
selected.incrementCountAndAddPrimaryKey(checked.getValue("id"));
}
@@ -151,7 +169,7 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public static class Delete extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final AttachmentsService service;
private AttachmentsService service;
private final ProcessSummaryLine deleted = line(Status.OK, "will be deleted", "deleted");
private final ProcessSummaryLine failed = line(Status.ERROR, "could not be deleted", "could not be deleted (retry required)");
@@ -160,7 +178,11 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public Delete()
{
this(new AttachmentsService());
//////////////////////////////////////////////////////////////////////////
// no QContext here: QQQ constructs steps during context-free instance //
// validation, so the service is created on first use (in a run) //
//////////////////////////////////////////////////////////////////////////
this(null);
}
/*******************************************************************************
@@ -171,6 +193,20 @@ public final class DeleteAttachmentsProcess
this.service = service;
}
/*******************************************************************************
** The attachments service, created on first use inside a run (QContext exists).
*******************************************************************************/
private AttachmentsService service()
{
if(service == null)
{
service = new AttachmentsService();
}
return (service);
}
/*******************************************************************************
** Count success only after both storage and row deletion succeed.
*******************************************************************************/
@@ -181,7 +217,7 @@ public final class DeleteAttachmentsProcess
{
try
{
service.delete(List.of(row.getValue("id")));
service().delete(List.of(row.getValue("id")));
deleted.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
catch(QException | RuntimeException expected)
@@ -288,7 +288,7 @@ public class CommentsService
new FollowService().ensureFollower(user, table, id, "MENTIONED");
if(!user.equals(PersonalRecords.currentUserId()))
{
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment", mention.getValueInteger("id"));
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment on ", mention.getValueInteger("id"));
notified.add(user);
}
}
@@ -307,7 +307,7 @@ public class CommentsService
if(!author.equals(follower.userId()) && !mentioned.contains(follower.userId()) && Boolean.TRUE.equals(follower.notifyOnComment())
&& (follower.mutedUntil() == null || !follower.mutedUntil().isAfter(Instant.now())))
{
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on a followed record", null);
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on ", null);
}
}
}
@@ -317,8 +317,10 @@ public class CommentsService
*******************************************************************************/
private void notify(QRecord row, String user, CollaborationNotification.Category category, String title, Integer mentionId) throws QException
{
Notifications.send(new CollaborationNotification(user, title, row.getValueString("body"), category,
row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId"))),
Notifications.send(new CollaborationNotification(user, title
+ new CollaborationRecordAccessChecker().notificationLabel(user, row.getValueString("tableName"), row.getValueString("recordId")), row.getValueString("body"), category,
row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId")))
.withEvent(mentionId == null ? row.getValue("id") : mentionId, PersonalRecords.currentUserId()),
mentionId == null ? null : () -> CollaborationRecords.update(CollaborationTables.MENTION,
new QRecord().withValue("id", mentionId).withValue("notifiedAt", Instant.now())));
}
@@ -144,7 +144,7 @@ public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
case "SELECT", "MULTI_SELECT" ->
{
String source = "collaboration_field_options_" + definition.getValueInteger("id");
instance.addPossibleValueSource(new QPossibleValueSource().withName(source).withType(QPossibleValueSourceType.ENUM)
instance.getPossibleValueSources().put(source, new QPossibleValueSource().withName(source).withType(QPossibleValueSourceType.ENUM)
.withEnumValues(new ArrayList<>(FieldDefinitions.options(definition).values())));
field.withPossibleValueSourceName(source);
if("MULTI_SELECT".equals(definition.getValueString("type")))
@@ -0,0 +1,256 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.email;
import java.util.List;
import java.util.function.Function;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier;
import com.kingsrook.qbits.collaboration.people.PeopleService;
import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
import com.kingsrook.qbits.email.api.EmailApiStatus;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
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.QInstance;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Delivers mention, share and task notifications through qbit-email, one
** NOTIFICATION message per recipient. This is the only class that references
** qbit-email; Notifications reaches it only after checking that the email qbit
** is loadable and produced in the instance.
**
** Emails never carry comment bodies, task titles or notes (they may hold PHI):
** they say who did what on which record, with a link. The record label is
** included only when the recipient may read the record and every label field is
** fully accessible; otherwise a generic label is used.
*******************************************************************************/
public final class EmailCollaborationNotifier implements CollaborationNotifier
{
private static final QLogger LOG = QLogger.getLogger(EmailCollaborationNotifier.class);
public static final String OWNER_QBIT = "qbit-collaboration";
public static final String MENTION_TEMPLATE = "collaboration_mention";
public static final String SHARE_TEMPLATE = "collaboration_share";
public static final String TASK_REMINDER_TEMPLATE = "collaboration_task_reminder";
private static final String SAMPLE = "{\"actorName\":\"Alex Example\",\"recordLabel\":\"Order 1042\",\"link\":\"https://example.invalid/app/orders/1042\"}";
private static final String OPEN_LINK = "{{#if link}}[Open the record]({{link}}){.button}{{/if}}";
/*******************************************************************************
** True when the email qbit is produced in the given instance.
*******************************************************************************/
public static boolean isAvailable(QInstance qInstance)
{
return (EmailApi.isInstalled(qInstance));
}
/*******************************************************************************
** Enqueue the email for a mention, share or task notification; follow digests
** have no email template. Failures are logged by user id and never thrown, so
** a mail problem cannot fail the collaboration action.
*******************************************************************************/
@Override
public void notify(CollaborationNotification n)
{
EmailApiTemplate template = templateFor(n.category());
if(template == null)
{
return;
}
try
{
String address = address(n.recipientUserId());
if(!StringUtils.hasContent(address))
{
LOG.info("Collaboration email skipped: no address for recipient", logPair("userId", n.recipientUserId()));
return;
}
EmailApi.ensureTemplate(template);
String eventId = n.sourceId() != null ? n.sourceId() : n.sourceRecordId();
EmailApiRequest request = new EmailApiRequest().withTemplateName(template.getName()).withCategory(EmailApiCategory.NOTIFICATION).withTo(address)
.withIdempotencyKey("collaboration:" + kind(n.category()) + ":" + eventId + ":" + n.recipientUserId())
.withSender(OWNER_QBIT, "notify-" + kind(n.category()))
.withVariable("actorName", actorName(n.actorUserId())).withVariable("recordLabel", recordLabel(n));
if(n.sourceTableName() != null && n.sourceRecordId() != null)
{
request.withLinkedRecord(n.sourceTableName(), n.sourceRecordId());
}
if(n.link() != null && n.link().startsWith("http"))
{
request.withVariable("link", n.link());
}
EmailApiResult result = EmailApi.send(request);
if(result.status() != EmailApiStatus.QUEUED)
{
LOG.info("Collaboration email not queued", logPair("userId", n.recipientUserId()), logPair("status", result.status()),
logPair("messageId", result.messageId()));
}
}
catch(QException | RuntimeException e)
{
LOG.warn("Collaboration email failed", e, logPair("userId", n.recipientUserId()), logPair("category", n.category()));
}
}
/*******************************************************************************
** The recipient address from the configured resolver, else from the
** collaboration people directory.
*******************************************************************************/
private String address(String userId) throws QException
{
Function<String, String> resolver = CollaborationRecords.config().getRecipientEmailResolver();
if(resolver != null)
{
return (resolver.apply(userId));
}
List<QRecord> rows = CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, userId));
return (rows.isEmpty() ? null : rows.getFirst().getValueString("email"));
}
/*******************************************************************************
** The person's display name, or a generic word when the actor is unknown.
*******************************************************************************/
private String actorName(String actorUserId) throws QException
{
return (actorUserId == null ? "Someone" : new PeopleService().displayName(actorUserId));
}
/*******************************************************************************
** The record label when the recipient may see it, else a generic phrase.
*******************************************************************************/
private String recordLabel(CollaborationNotification n) throws QException
{
if(n.sourceTableName() == null)
{
return ("a record");
}
return (new CollaborationRecordAccessChecker().recordLabel(n.recipientUserId(), n.sourceTableName(), n.sourceRecordId())
.orElse("a record in " + QContext.getQInstance().getTable(n.sourceTableName()).getLabel()));
}
/*******************************************************************************
** Idempotency kind for a category.
*******************************************************************************/
private static String kind(CollaborationNotification.Category category)
{
return (switch(category)
{
case MENTION -> "mention";
case SHARE -> "share";
default -> "task";
});
}
/*******************************************************************************
** The shipped template for a category, or null when none exists.
*******************************************************************************/
static EmailApiTemplate templateFor(CollaborationNotification.Category category)
{
return (switch(category)
{
case MENTION -> mentionTemplate();
case SHARE -> shareTemplate();
case TASK -> taskReminderTemplate();
default -> null;
});
}
/*******************************************************************************
** Shared definition: variable schema and ownership. Existing templates, and
** people's edits, are never touched. Labels are CONFIDENTIAL, not PII or PHI:
** the template needs no PHI approval because labels are passed only when the
** record label fields are fully accessible.
*******************************************************************************/
private static EmailApiTemplate definition(String name, String label, String subject, String body)
{
return (new EmailApiTemplate().withName(name).withLabel(label).withCategory(EmailApiCategory.NOTIFICATION).withKind("MARKDOWN")
.withOwnerQbit(OWNER_QBIT).withSubject(subject).withBody(body)
.withVariable("actorName", "Person who acted", "STRING", true, "CONFIDENTIAL")
.withVariable("recordLabel", "Record label", "STRING", true, "CONFIDENTIAL")
.withVariable("link", "Link to the record", "URL", false, "PUBLIC")
.withSampleVariables(SAMPLE));
}
/*******************************************************************************
** Someone mentioned the recipient.
*******************************************************************************/
static EmailApiTemplate mentionTemplate()
{
return (definition(MENTION_TEMPLATE, "Collaboration mention", "{{actorName}} mentioned you on {{recordLabel}}",
"**{{actorName}}** mentioned you on {{recordLabel}}.\n\n" + OPEN_LINK));
}
/*******************************************************************************
** Someone shared a record with the recipient.
*******************************************************************************/
static EmailApiTemplate shareTemplate()
{
return (definition(SHARE_TEMPLATE, "Collaboration share", "{{actorName}} shared {{recordLabel}} with you",
"**{{actorName}}** shared {{recordLabel}} with you.\n\n" + OPEN_LINK));
}
/*******************************************************************************
** A task assigned to the recipient was assigned, is due, or is overdue.
*******************************************************************************/
static EmailApiTemplate taskReminderTemplate()
{
return (definition(TASK_REMINDER_TEMPLATE, "Collaboration task reminder", "A task on {{recordLabel}} needs your attention",
"A task assigned to you on {{recordLabel}} needs your attention.\n\n" + OPEN_LINK));
}
}
@@ -70,7 +70,7 @@ public final class FavoritesService
.withOrderBy(new QFilterOrderBy("sortOrder", false)).withLimit(1));
Integer order = last.isEmpty() ? 0 : Math.addExact(last.getFirst().getValueInteger("sortOrder"), 1);
QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName)
.withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("sortOrder", order);
.withValue("recordId", target.recordId()).withValue("label", TargetResolver.cacheableLabel(target)).withValue("sortOrder", order);
try
{
PersonalRecords.insert(CollaborationTables.FAVORITE, row);
@@ -0,0 +1,70 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.follow;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowersProviderInterface;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Core event subscribers; consumers must check each recipient's record access.
*******************************************************************************/
public class CollaborationFollowersProvider implements RecordFollowersProviderInterface
{
/*******************************************************************************
** Read only this parent's subscriptions as the system, independent of the caller.
*******************************************************************************/
@Override
public List<RecordFollower> getFollowers(QInstance instance, RecordReference reference, FollowEvent event) throws QException
{
List<RecordFollower> followers = new ArrayList<>();
QContext.withTemporaryContext(new CapturedContext(instance, new QSystemUserSession(instance)), () ->
{
Instant now = Instant.now();
String preference = event == FollowEvent.COMMENT ? "notifyOnComment" : "notifyOnChange";
for(QRecord row : CollaborationRecords.query(CollaborationTables.FOLLOW,
CollaborationRecords.parent(reference.tableName(), reference.primaryKey().toString())))
{
if(Boolean.TRUE.equals(row.getValueBoolean(preference))
&& (row.getValueInstant("mutedUntil") == null || !row.getValueInstant("mutedUntil").isAfter(now)))
{
followers.add(new RecordFollower().withUserId(row.getValueString("userId"))
.withNotifyOnChange(row.getValueBoolean("notifyOnChange")).withNotifyOnComment(row.getValueBoolean("notifyOnComment"))
.withReason(row.getValueString("reason")));
}
}
});
return (followers);
}
}
@@ -132,7 +132,7 @@ public final class CollaborationTables
enumSource(FEATURE, "ATTACHMENTS", "TAGS", "CUSTOM_FIELDS", "COMMENTS", "PRESENCE", "SHARING",
"FAVORITES", "FOLLOW", "TIMELINE", "RECENTS", "TASKS", "ASSIGNMENT"),
enumSource(PERMISSION_MODE, "EDIT_OR_GRANTED", "GRANTED_ONLY"),
QPossibleValueSource.newForTable(FEATURE_SETTING), QPossibleValueSource.newForTable(USER)));
QPossibleValueSource.newForTable(FEATURE_SETTING), QPossibleValueSource.newForTable(USER).withOverrideIdField("userId")));
}
@@ -239,7 +239,7 @@ public final class CollaborationTables
.withRecordSecurityLock(CollaborationSecurity.ownerLock("userId"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE))
.withRecordLabelFormatAndFields("%s", "label")
.withField(string("userId", "User ID", 255).withIsRequired(true))
.withField(user("userId", "User ID").withIsRequired(true))
.withField(string("targetType", "Target Type", 20).withIsRequired(true).withPossibleValueSourceName(TARGET_TYPE))
.withField(string("targetName", "Target Name", 255).withIsRequired(true))
.withField(string("recordId", "Record ID", 255))
@@ -251,6 +251,15 @@ public final class CollaborationTables
/*******************************************************************************
** A user-id string shown by display name wherever the table is browsed.
*******************************************************************************/
private static QFieldMetaData user(String name, String label)
{
return (string(name, label, 255).withPossibleValueSourceName(USER));
}
/*******************************************************************************
** A bounded string field.
*******************************************************************************/
@@ -280,7 +289,7 @@ public final class CollaborationTables
.withField(string("storageKey", "Storage Key", 1024).withIsRequired(true))
.withField(string("thumbnailKey", "Thumbnail Key", 1024))
.withField(new QFieldMetaData("description", QFieldType.TEXT).withLabel("Description"))
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
.withField(user("createdByUserId", "Created By").withIsRequired(true))
.withField(new QFieldMetaData("parentDeleted", QFieldType.BOOLEAN).withLabel("Parent Deleted").withIsRequired(true).withDefaultValue(false))
.withVirtualField(new QVirtualFieldMetaData("previewUrl", QFieldType.STRING).withLabel("Preview").withIsEditable(false).withIsQuerySelectable(true)
.withFieldAdornment(AdornmentType.FilePreviewValues.newFieldAdornment()
@@ -322,28 +331,28 @@ public final class CollaborationTables
.withField(new QFieldMetaData("body", QFieldType.TEXT).withMaxLength(20000))
// TODO: add .withDependsOn("tableName") when QQQ core ships contingent dependsOn (docs/CONTINGENT-POSSIBLE-VALUES.md)
.withField(string("fieldName", "Field", 128).withPossibleValueSourceName(RECORD_FIELD_NAME))
.withField(string("authorUserId", "Author", 255).withIsRequired(true))
.withField(user("authorUserId", "Author").withIsRequired(true))
.withField(new QFieldMetaData("lastActivityAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(new QFieldMetaData("editedAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("resolvedAt", QFieldType.DATE_TIME))
.withField(string("resolvedByUserId", "Resolved By", 255))
.withField(user("resolvedByUserId", "Resolved By"))
.withField(new QFieldMetaData("deletedAt", QFieldType.DATE_TIME))
.withField(string("deletedByUserId", "Deleted By", 255))
.withField(user("deletedByUserId", "Deleted By"))
.withRecordLabelFormatAndFields("Comment %s", "id");
QTableMetaData revisions = referenced(config, COMMENT_REVISION, "Comment History", "history")
.withField(new QFieldMetaData("commentId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(COMMENT))
.withField(new QFieldMetaData("body", QFieldType.TEXT))
.withField(new QFieldMetaData("editedAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(string("editedByUserId", "Edited By", 255));
.withField(user("editedByUserId", "Edited By"));
QTableMetaData mentions = referenced(config, MENTION, "Mentions", "alternate_email")
.withField(new QFieldMetaData("commentId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(COMMENT))
.withField(string("mentionedUserId", "Mentioned User", 255).withIsRequired(true))
.withField(user("mentionedUserId", "Mentioned User").withIsRequired(true))
.withField(new QFieldMetaData("visible", QFieldType.BOOLEAN).withIsRequired(true))
.withField(new QFieldMetaData("readAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("notifiedAt", QFieldType.DATE_TIME))
.withUniqueKey(new UniqueKey("commentId", "mentionedUserId"));
QTableMetaData follows = referenced(config, FOLLOW, "Followers", "notifications")
.withField(string("userId", "User", 255).withIsRequired(true))
.withField(user("userId", "User").withIsRequired(true))
.withField(string("reason", "Reason", 32).withIsRequired(true))
.withField(new QFieldMetaData("notifyOnChange", QFieldType.BOOLEAN).withIsRequired(true))
.withField(new QFieldMetaData("notifyOnComment", QFieldType.BOOLEAN).withIsRequired(true))
@@ -353,13 +362,13 @@ public final class CollaborationTables
.withoutCapabilities(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT,
com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE)
.withField(new QFieldMetaData("at", QFieldType.DATE_TIME).withIsRequired(true))
.withField(string("actorUserId", "Actor", 255))
.withField(user("actorUserId", "Actor"))
.withField(string("type", "Type", 64).withIsRequired(true))
.withField(new QFieldMetaData("summary", QFieldType.TEXT))
.withField(string("refTableName", "Reference Table", 128))
.withField(string("refId", "Reference ID", 64));
QTableMetaData digests = referenced(config, FOLLOW_DIGEST, "Follow Digests", "notifications")
.withField(string("userId", "Recipient", 255).withIsRequired(true))
.withField(user("userId", "Recipient").withIsRequired(true))
.withField(new QFieldMetaData("dueAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(new QFieldMetaData("changes", QFieldType.TEXT))
.withUniqueKey(new UniqueKey("tableName", "recordId", "userId"));
@@ -411,7 +420,7 @@ public final class CollaborationTables
.withField(string("tableName", "Table", 128))
.withField(new QFieldMetaData("approved", QFieldType.BOOLEAN).withIsRequired(true))
.withField(new QFieldMetaData("description", QFieldType.TEXT))
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
.withField(user("createdByUserId", "Created By").withIsRequired(true))
.withField(new QFieldMetaData("usageCount", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(0))
.withField(new QFieldMetaData("mergedIntoTagId", QFieldType.INTEGER).withPossibleValueSourceName(TAG))
.withUniqueKey(new UniqueKey("tableName", "name"))
@@ -421,7 +430,7 @@ public final class CollaborationTables
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.security.CollaborationRowSecurity.class)))
.withField(new QFieldMetaData("tagId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(TAG))
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
.withField(user("createdByUserId", "Created By").withIsRequired(true))
.withUniqueKey(new UniqueKey("tableName", "recordId", "tagId"))
.withSection(new QFieldSection("details", new QIcon("label"), Tier.T1, List.of("id", "tagId", "createdByUserId")).withLabel("Details"));
for(QTableMetaData table : List.of(tags, links))
@@ -437,8 +446,8 @@ public final class CollaborationTables
private static List<QTableMetaData> workTables(CollaborationQBitConfig config)
{
QTableMetaData shares = referenced(config, SHARE, "Shares", "share")
.withField(string("fromUserId", "From", 255).withIsRequired(true))
.withField(string("toUserId", "To", 255).withIsRequired(true))
.withField(user("fromUserId", "From").withIsRequired(true))
.withField(user("toUserId", "To").withIsRequired(true))
.withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000))
.withField(new QFieldMetaData("recipientCouldRead", QFieldType.BOOLEAN))
.withField(new QFieldMetaData("sentAt", QFieldType.DATE_TIME).withIsRequired(true))
@@ -451,29 +460,29 @@ public final class CollaborationTables
.withRecordLabelFormatAndFields("Access request %s", "id")
.withField(string("tableName", "Table", 128).withIsRequired(true))
.withField(string("recordId", "Record ID", 64).withIsRequired(true))
.withField(string("requesterUserId", "Requester", 255).withIsRequired(true))
.withField(user("requesterUserId", "Requester").withIsRequired(true))
.withField(new QFieldMetaData("shareId", QFieldType.INTEGER))
.withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000))
.withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_access_request_status"))
.withField(string("handledByUserId", "Handled By", 255))
.withField(user("handledByUserId", "Handled By"))
.withField(new QFieldMetaData("handledAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("note", QFieldType.TEXT).withMaxLength(2000));
QTableMetaData tasks = referenced(config, TASK, "Tasks", "task_alt")
.withRecordLabelFormatAndFields("%s", "title").withSearchFields(List.of("title"))
.withField(string("title", "Title", 200).withIsRequired(true))
.withField(new QFieldMetaData("notes", QFieldType.TEXT))
.withField(string("assigneeUserId", "Assignee", 255))
.withField(user("assigneeUserId", "Assignee"))
.withField(new QFieldMetaData("dueAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("remindAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("remindedAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("overdueNotifiedAt", QFieldType.DATE_TIME))
.withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_task_status"))
.withField(new QFieldMetaData("completedAt", QFieldType.DATE_TIME))
.withField(string("completedByUserId", "Completed By", 255))
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true));
.withField(user("completedByUserId", "Completed By"))
.withField(user("createdByUserId", "Created By").withIsRequired(true));
QTableMetaData assignments = referenced(config, RECORD_ASSIGNMENT, "Record Owners", "assignment_ind")
.withField(string("ownerUserId", "Owner", 255))
.withField(string("assigneeUserId", "Assignee", 255))
.withField(user("ownerUserId", "Owner"))
.withField(user("assigneeUserId", "Assignee"))
.withUniqueKey(new UniqueKey("tableName", "recordId"));
List<QTableMetaData> tables = List.of(shares, requests, tasks, assignments);
for(QTableMetaData table : tables)
@@ -519,7 +528,7 @@ public final class CollaborationTables
.withField(string("storage", "Storage", 16).withIsEditable(false))
.withField(new QFieldMetaData("statusMessage", QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData("indexDdl", QFieldType.TEXT).withLabel("Index DDL").withIsEditable(false))
.withField(string("createdByUserId", "Created by", 255).withIsEditable(false))
.withField(user("createdByUserId", "Created by").withIsEditable(false))
.withUniqueKey(new UniqueKey("tableName", "name"));
QCodeReference customizer = new QCodeReference(com.kingsrook.qbits.collaboration.customfields.FieldDefinitionCustomizer.class);
definitions.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, customizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, customizer)
@@ -24,10 +24,35 @@ package com.kingsrook.qbits.collaboration.notify;
/*******************************************************************************
** Transport-neutral notification. Body is markdown; link is a record href.
** sourceId identifies the event (mention, share, task or assignment row) so a
** transport can de-duplicate; actorUserId is the person who caused it. Both are
** optional.
*******************************************************************************/
public record CollaborationNotification(String recipientUserId, String title, String body, Category category,
String sourceTableName, String sourceRecordId, String link)
String sourceTableName, String sourceRecordId, String link, String sourceId, String actorUserId)
{
/*******************************************************************************
** Notification without an event id or actor.
*******************************************************************************/
public CollaborationNotification(String recipientUserId, String title, String body, Category category,
String sourceTableName, String sourceRecordId, String link)
{
this(recipientUserId, title, body, category, sourceTableName, sourceRecordId, link, null, null);
}
/*******************************************************************************
** Copy carrying the event id and actor.
*******************************************************************************/
public CollaborationNotification withEvent(Object eventId, String actor)
{
return (new CollaborationNotification(recipientUserId, title, body, category, sourceTableName, sourceRecordId, link,
eventId == null ? null : String.valueOf(eventId), actor));
}
/*******************************************************************************
** Categories supported by the collaboration notification boundary.
*******************************************************************************/
@@ -22,11 +22,13 @@
package com.kingsrook.qbits.collaboration.notify;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.email.EmailCollaborationNotifier;
import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeVoidVoidMethod;
/*******************************************************************************
@@ -99,7 +101,7 @@ public final class Notifications
{
if(!checkParent || new CollaborationRecordAccessChecker().canRead(notification.recipientUserId(), notification.sourceTableName(), notification.sourceRecordId()).orElse(false))
{
CollaborationNotifier notifier = QCodeLoader.getAdHoc(CollaborationNotifier.class, CollaborationRecords.config().getNotifier());
CollaborationNotifier notifier = notifier();
notifier.notify(notification);
if(!(notifier instanceof NoOpNotifier))
{
@@ -121,6 +123,41 @@ public final class Notifications
}
}
/*******************************************************************************
** The configured notifier; with none configured, email through qbit-email when
** it is on the classpath and produced in this instance, else no transport.
*******************************************************************************/
private static CollaborationNotifier notifier() throws QException
{
QCodeReference configured = CollaborationRecords.config().getNotifier();
if(configured != null)
{
return (QCodeLoader.getAdHoc(CollaborationNotifier.class, configured));
}
if(emailAvailable())
{
return (new EmailCollaborationNotifier());
}
return (new NoOpNotifier());
}
/*******************************************************************************
** qbit-email is optional: the adapter is touched only after the class check
** passes and the email qbit is produced in the current instance.
*******************************************************************************/
private static boolean emailAvailable()
{
try
{
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
return (EmailCollaborationNotifier.isAvailable(QContext.getQInstance()));
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
/*******************************************************************************
** Relative canonical frontend link with separately encoded path segments.
*******************************************************************************/
@@ -109,10 +109,11 @@ public class PeopleService
/*******************************************************************************
** Rank participants before limiting, with bounded backend queries for both groups.
** The caller never appears in the results: nobody shares with or mentions themself.
*******************************************************************************/
public List<PersonEntry> search(String q, Integer limit, Set<String> preferredUserIds) throws QException
{
PersonalRecords.currentUserId();
String me = PersonalRecords.currentUserId();
if(limit == null || limit < 1 || limit > 100)
{
throw (new QBadRequestException("Limit must be between 1 and 100"));
@@ -120,12 +121,12 @@ public class PeopleService
List<QRecord> rows = new ArrayList<>();
if(!preferredUserIds.isEmpty())
{
rows.addAll(CollaborationRecords.query(CollaborationTables.USER, searchFilter(q, limit)
rows.addAll(CollaborationRecords.query(CollaborationTables.USER, searchFilter(q, limit, me)
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferredUserIds))));
}
if(rows.size() < limit)
{
QQueryFilter other = searchFilter(q, limit - rows.size());
QQueryFilter other = searchFilter(q, limit - rows.size(), me);
if(!preferredUserIds.isEmpty())
{
other.withCriteria("userId", QCriteriaOperator.NOT_IN, new ArrayList<>(preferredUserIds));
@@ -139,9 +140,10 @@ public class PeopleService
/*******************************************************************************
** Keep active and group restrictions outside the display-name/email OR.
*******************************************************************************/
private QQueryFilter searchFilter(String q, Integer limit)
private QQueryFilter searchFilter(String q, Integer limit, String excludedUserId)
{
QQueryFilter filter = new QQueryFilter().withCriteria("active", QCriteriaOperator.EQUALS, true)
.withCriteria("userId", QCriteriaOperator.NOT_EQUALS, excludedUserId)
.withOrderBy(new QFilterOrderBy("displayName")).withOrderBy(new QFilterOrderBy("userId")).withLimit(limit);
if(q != null && !q.isBlank())
{
@@ -161,6 +163,37 @@ public class PeopleService
return (rows.isEmpty() || rows.getFirst().getValueString("displayName") == null ? userId : rows.getFirst().getValueString("displayName"));
}
/*******************************************************************************
** Resolve many display names with one directory query. Unknown ids map to
** themselves, so a caller always has something readable to show.
*******************************************************************************/
public java.util.Map<String, String> displayNames(java.util.Collection<String> userIds) throws QException
{
java.util.Map<String, String> names = new java.util.LinkedHashMap<>();
Set<String> ids = new java.util.LinkedHashSet<>();
for(String id : userIds)
{
if(id != null && !id.isBlank())
{
ids.add(id);
}
}
if(ids.isEmpty())
{
return (names);
}
for(QRecord row : CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(ids))))
{
String name = row.getValueString("displayName");
if(name != null && !name.isBlank())
{
names.put(row.getValueString("userId"), name);
}
}
ids.forEach(id -> names.putIfAbsent(id, id));
return (names);
}
/*******************************************************************************
** Look up one active person with the same signed-in boundary as search.
*******************************************************************************/
@@ -70,7 +70,7 @@ public final class RecentsService
if(existing.isEmpty())
{
QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName)
.withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("lastViewedAt", now).withValue("viewCount", 1);
.withValue("recordId", target.recordId()).withValue("label", TargetResolver.cacheableLabel(target)).withValue("lastViewedAt", now).withValue("viewCount", 1);
try
{
PersonalRecords.insert(CollaborationTables.RECENT, row);
@@ -92,7 +92,7 @@ public final class RecentsService
return;
}
PersonalRecords.update(CollaborationTables.RECENT, List.of(new QRecord().withValue("id", row.getValue("id"))
.withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", target.label())));
.withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", TargetResolver.cacheableLabel(target))));
}
@@ -27,10 +27,14 @@ import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
@@ -82,4 +86,50 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
});
return (Optional.of(result.get()));
}
/*******************************************************************************
** The record label as the recipient may see it: empty unless a session for the
** recipient exists, the record is readable, and every label field is fully
** accessible under the recipient session's field-access policy.
*******************************************************************************/
public Optional<String> recordLabel(String userId, String table, Serializable recordId) throws QException
{
CollaborationQBitConfig config = CollaborationRecords.config();
if(config.getUserSessionProvider() == null || table == null || recordId == null)
{
return (Optional.empty());
}
Optional<QSession> session = QCodeLoader.getAdHoc(com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface.class, config.getUserSessionProvider()).sessionForUser(userId);
if(session.isEmpty() || session.get().getUser() == null || !userId.equals(session.get().getUser().getIdReference()))
{
return (Optional.empty());
}
AtomicReference<String> result = new AtomicReference<>();
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), session.get()), () ->
{
try
{
ParentRecordAccess.requireVisibleParent(table, recordId);
if(FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(table)))
{
GetInput input = new GetInput(table).withPrimaryKey(recordId).withShouldGenerateDisplayValues(true);
QRecord record = new GetAction().executeForRecord(input);
result.set(record == null ? null : record.getRecordLabel());
}
}
catch(QNotFoundException expected)
{
// Not readable by the recipient: no label.
}
});
return (Optional.ofNullable(result.get()).filter(label -> !label.isBlank()));
}
/*******************************************************************************
** Notification titles fall back to public table metadata and the record id.
*******************************************************************************/
public String notificationLabel(String userId, String table, Serializable recordId) throws QException
{
return (recordLabel(userId, table, recordId).orElse(QContext.getQInstance().getTable(table).getLabel() + " #" + recordId));
}
}
@@ -97,8 +97,9 @@ public class SharingService
.withValue("recipientCouldRead", recipient.canRead()).withValue("sentAt", Instant.now()).withValue("parentDeleted", false));
if(Boolean.TRUE.equals(recipient.canRead()))
{
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared " + link.label() + " with you", message,
CollaborationNotification.Category.SHARE, table, id, link.url()));
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared "
+ new CollaborationRecordAccessChecker().notificationLabel(recipient.userId(), table, id) + " with you", message,
CollaborationNotification.Category.SHARE, table, id, link.url()).withEvent(row.getValue("id"), from));
new FollowService().ensureFollower(recipient.userId(), table, id, "SHARED");
}
else
@@ -106,7 +107,7 @@ public class SharingService
Notifications.sendAccessNotice(new CollaborationNotification(recipient.userId(), name + " wants to share a record in "
+ QContext.getQInstance().getTable(table).getLabel() + " with you", "Request access to open this record.",
CollaborationNotification.Category.SHARE, null, null, "/app/collaboration_access_request/create?tableName="
+ TargetResolver.encode(table) + "&recordId=" + TargetResolver.encode(id) + "&shareId=" + row.getValueString("id")));
+ TargetResolver.encode(table) + "&recordId=" + TargetResolver.encode(id) + "&shareId=" + row.getValueString("id")).withEvent(row.getValue("id"), from));
}
ActivityWriter.write(table, id, "SHARED", "Record shared", CollaborationTables.SHARE, row.getValueString("id"));
result.add(row);
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.collaboration.tags;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.tables.query.CriteriaExpansionContext;
import com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind;
@@ -71,7 +72,7 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac
}
/*******************************************************************************
** Numeric values are picker IDs; strings are exact normalized vocabulary names.
** Numeric values (or numeric strings naming an existing tag) are picker IDs; other strings are exact normalized names.
*******************************************************************************/
private QQueryFilter membership(QTableMetaData table, List<Serializable> values, boolean negate, boolean any) throws QException
{
@@ -90,8 +91,8 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac
{
throw (new QUserFacingException("Tags cannot contain null values"));
}
choices.withCriteria(value instanceof Number ? "id" : "name", QCriteriaOperator.EQUALS,
value instanceof Number ? value : TagsService.normalize(value.toString()));
Integer id = tagId(table, value);
choices.withCriteria(id != null ? "id" : "name", QCriteriaOperator.EQUALS, id != null ? id : TagsService.normalize(value.toString()));
}
tags.withSubFilter(choices);
links.withCriteria("tagId", QCriteriaOperator.IN, new QSubQuery(CollaborationTables.TAG, "id", tags));
@@ -100,6 +101,30 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac
new QSubQuery(CollaborationTables.RECORD_TAG, "recordId", links)));
}
/*******************************************************************************
** A value is a picker id when it is a Number (any JSON numeric type) or a String
** holding an integer that matches an existing tag; otherwise it is a name (null).
*******************************************************************************/
private Integer tagId(QTableMetaData table, Serializable value) throws QException
{
if(value instanceof Number number)
{
return (number.intValue());
}
String text = value.toString().strip();
try
{
int id = Integer.parseInt(text);
boolean exists = !CollaborationRecords.query(CollaborationTables.TAG, TagsService.vocabularyFilter(table.getName())
.withCriteria("id", QCriteriaOperator.EQUALS, id)).isEmpty();
return (exists ? id : null);
}
catch(NumberFormatException e)
{
return (null);
}
}
/*******************************************************************************
** Only live associations on this parent table participate.
*******************************************************************************/
@@ -83,7 +83,7 @@ public class TaskReminders
String id = row.getValueString("recordId");
Notifications.send(new CollaborationNotification(row.getValueString("assigneeUserId"),
(dueField.equals("remindAt") ? "Task reminder: " : "Task overdue: ") + row.getValueString("title"), row.getValueString("notes"),
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)),
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)).withEvent(row.getValue("id") + ":" + dueField, null),
() -> CollaborationRecords.update(CollaborationTables.TASK, new QRecord().withValue("id", row.getValue("id")).withValue(receipt, now)));
}
}
@@ -266,7 +266,8 @@ public class TasksService
if(!user.equals(PersonalRecords.currentUserId()))
{
Notifications.send(new CollaborationNotification(user, "Task assigned: " + row.getValueString("title"), row.getValueString("notes"),
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)));
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id))
.withEvent(row.getValue("id"), PersonalRecords.currentUserId()));
}
}
}
@@ -56,6 +56,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
*******************************************************************************/
public class TimelineService
{
private static final int MAX_FIELDS_IN_SUMMARY = 4;
/*******************************************************************************
** Types accepts AUDIT/ACTIVITY or individual collaboration activity type names.
*******************************************************************************/
@@ -154,8 +156,10 @@ public class TimelineService
List<TimelineItem> result = new ArrayList<>();
for(QRecord row : headers)
{
result.add(new TimelineItem("AUDIT", row.getValueLong("id"), row.getValueInstant("timestamp"), null, actors.get(row.getValueInteger("auditUserId")),
row.getValueString("message"), List.copyOf(details.getOrDefault(row.getValueLong("id"), List.of())), AuditsMetaDataProvider.TABLE_NAME_AUDIT, row.getValueString("id")));
String actor = actors.get(row.getValueInteger("auditUserId"));
List<TimelineDetail> rowDetails = List.copyOf(details.getOrDefault(row.getValueLong("id"), List.of()));
result.add(new TimelineItem("AUDIT", row.getValueLong("id"), row.getValueInstant("timestamp"), null, actor,
friendlySummary(row.getValueString("message"), actor, rowDetails, personalized), rowDetails, AuditsMetaDataProvider.TABLE_NAME_AUDIT, row.getValueString("id")));
}
return (result);
}
@@ -165,6 +169,40 @@ public class TimelineService
}
}
/*******************************************************************************
** Core words its audits "Record was Edited" or "Record was Inserted"; the timeline
** says who did what instead: "Edited by Alice Morgan: Status, Total" or "Created by
** Alice Morgan". The detail rows are unchanged. Any other message is kept as written.
*******************************************************************************/
public static String friendlySummary(String message, String actor, List<TimelineDetail> details, QTableMetaData table)
{
String verb = message == null ? null : message.startsWith("Record was Inserted") ? "Created" : message.startsWith("Record was Edited") ? "Edited"
: message.startsWith("Record was Deleted") ? "Deleted" : null;
if(verb == null)
{
return (message);
}
StringBuilder summary = new StringBuilder(verb);
if(actor != null && !actor.isBlank())
{
summary.append(" by ").append(actor);
}
if(verb.equals("Edited"))
{
List<String> labels = details.stream().map(TimelineDetail::fieldName).filter(name -> name != null && !name.isBlank()).distinct()
.map(name -> table != null && table.getFields().containsKey(name) && table.getFields().get(name).getLabel() != null ? table.getFields().get(name).getLabel() : name).toList();
if(!labels.isEmpty())
{
summary.append(": ").append(String.join(", ", labels.subList(0, Math.min(MAX_FIELDS_IN_SUMMARY, labels.size()))));
if(labels.size() > MAX_FIELDS_IN_SUMMARY)
{
summary.append(" and ").append(labels.size() - MAX_FIELDS_IN_SUMMARY).append(" more");
}
}
}
return (summary.toString());
}
/*******************************************************************************
** Explicit enforcement applies to every audit table, independent of host defaults.
*******************************************************************************/
@@ -0,0 +1,748 @@
bundle: qbit-collaboration
version: ${project.version}
layer: QBIT
entries:
- key: widget:collaborationKpis;slot:label
summary: "See counts of collaboration activity and work you can access."
- key: widget:collaborationTags;slot:label
summary: "Browse tags and their usage from the Collaboration home."
- key: widget:collaborationCustomFields;slot:label
summary: "Review custom fields and their status from the Collaboration home."
- key: widget:collaborationWhosHere;slot:label
summary: "See who is currently present, when you have collaboration administrator access."
- key: widget:collaboration_field_definitionJoinCollaboration_field_value;slot:label
summary: "Review values associated with this custom field."
- key: widget:collaboration_tagJoinCollaboration_record_tag;slot:label
summary: "See records carrying this tag."
- key: widget:collaboration_commentJoinCollaboration_comment_revision;slot:label
summary: "Review earlier versions of this comment."
- key: widget:collaboration_commentJoinCollaboration_mention;slot:label
summary: "See people mentioned in this comment."
- key: widget:collaboration_commentJoinCollaboration_follow;slot:label
summary: "See followers of the record containing this comment."
- key: app:collaboration;slot:help
summary: "Work together through record comments, attachments, tags, tasks and sharing."
content: |
Use Collaboration to review discussions, files and work associated with records. Features are available where they have been enabled, and access still depends on the underlying record permissions. Open Feature Settings to review availability, People to find participants, or the content tables to review what has been created.
Use the Setup card to see which features are enabled and where their settings come from.
- key: table:collaboration_feature_setting;slot:help
summary: "Control which record features are available across the site, in an app or on a table."
content: |
**How rows get created**
Use Feature Settings to turn features such as Comments, Tags and Attachments on or off. Choose the Scope Type and Scope Name to identify where your setting applies, then select the Feature. Enabled controls availability, while Permission Mode controls who can use it. Settings holds additional options for that feature.
To start with your configured settings, run Create rows from configuration while this table is empty. Review the proposed rows before continuing. This action leaves existing rows untouched. You can then edit the rows to override the configuration.
- key: table:collaboration_feature_setting;field:scopeType
summary: "Choose whether the setting applies to the whole site, an app or a table."
- key: table:collaboration_feature_setting;field:scopeName
summary: "Choose the app or table the setting applies to."
- key: table:collaboration_feature_setting;field:feature
summary: "Choose the record feature you want to configure."
- key: table:collaboration_feature_setting;field:enabled
summary: "Whether this feature is available in the selected scope."
- key: table:collaboration_feature_setting;field:settings
summary: "Additional options for the selected feature, stored as JSON."
- key: table:collaboration_feature_setting;field:permissionMode
summary: "Choose whether editing permission or an explicit feature permission allows use."
content: |
Permission Mode controls which permission allows someone to use the selected feature. The edit-or-granted option accepts either table editing permission or the specific feature permission. The granted-only option requires the specific feature permission. You still need access to the record, and the feature must be enabled for its scope.
- key: table:collaboration_user;slot:help
summary: "Find the people used in mentions, sharing and assignments."
content: |
**How rows get created**
People is the directory used when you mention someone, share a record or assign work. Entries are maintained automatically as signed-in people use collaboration features. You can review their display names, contact details and activity here, but you cannot edit these directory records by hand.
- key: table:collaboration_user;field:userId
summary: "The person this entry belongs to."
- key: table:collaboration_user;field:displayName
summary: "The name you see when selecting or mentioning this person."
- key: table:collaboration_user;field:email
summary: "The email address recorded for this person."
- key: table:collaboration_user;field:avatarUrl
summary: "The address of the image used for this person."
- key: table:collaboration_user;field:lastSeenAt
summary: "When this person was last seen using collaboration features."
- key: table:collaboration_user;field:active
summary: "Whether this person is marked active in the directory."
- key: table:collaboration_favorite;slot:help
summary: "Keep track of the records, tables and apps you have starred."
content: |
**How rows get created**
Star a record, table or app to keep it among your Favorites. Each entry identifies the destination and stores your chosen label, folder and sort order. Favorites belong to individual people, so your list reflects your own choices. An empty list means you have not starred anything available here.
- key: table:collaboration_favorite;field:userId
summary: "The person this entry belongs to."
- key: table:collaboration_favorite;field:targetType
summary: "The kind of destination, such as a record, table or app."
- key: table:collaboration_favorite;field:targetName
summary: "The destination this entry opens."
- key: table:collaboration_favorite;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_favorite;field:label
summary: "The name shown to you for this entry."
- key: table:collaboration_favorite;field:sortOrder
summary: "The position used to order this entry."
- key: table:collaboration_favorite;field:folder
summary: "The folder used to group your favorites."
- key: table:collaboration_recent;slot:help
summary: "Find records you opened recently and see when you last viewed them."
content: |
**How rows get created**
Recently Viewed is filled automatically when you open records with Recents enabled. Each entry keeps the destination, your latest viewing time and the number of views. You do not need to create these entries yourself. Older entries are removed by Trim recents according to the configured retention limits.
- key: table:collaboration_recent;field:userId
summary: "The person this entry belongs to."
- key: table:collaboration_recent;field:targetType
summary: "The kind of destination, such as a record, table or app."
- key: table:collaboration_recent;field:targetName
summary: "The destination this entry opens."
- key: table:collaboration_recent;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_recent;field:label
summary: "The name shown to you for this entry."
- key: table:collaboration_recent;field:lastViewedAt
summary: "When you most recently opened this destination."
- key: table:collaboration_recent;field:viewCount
summary: "How many times you have opened this destination."
- key: table:collaboration_attachment;slot:help
summary: "Browse files attached to records, with their descriptions and storage details."
content: |
**How rows get created**
Upload a file through the Attachments panel on a record to create an attachment. You can review the file name, type, size and description here, together with its parent record. The file is stored separately from this entry. Attachments must be enabled for the parent table before you can add files.
- key: table:collaboration_attachment;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_attachment;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_attachment;field:fileName
summary: "The name of the attached file."
- key: table:collaboration_attachment;field:contentType
summary: "The file type reported for the attachment."
- key: table:collaboration_attachment;field:sizeBytes
summary: "The size of the attached file in bytes."
- key: table:collaboration_attachment;field:sha256
summary: "The file fingerprint used to identify its contents."
- key: table:collaboration_attachment;field:bucketName
summary: "The storage location holding the attached file."
- key: table:collaboration_attachment;field:storageKey
summary: "The location of the file within its storage bucket."
- key: table:collaboration_attachment;field:thumbnailKey
summary: "The location of the smaller preview image in storage."
- key: table:collaboration_attachment;field:description
summary: "A description that helps you understand this entry."
- key: table:collaboration_attachment;field:createdByUserId
summary: "The person who created this entry."
- key: table:collaboration_attachment;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_comment;slot:help
summary: "Read record discussions, including replies, authors and resolution details."
content: |
**How rows get created**
Write comments from the Comments panel on a record where Comments is enabled. A discussion can contain replies and can be resolved when it no longer needs attention. You can review the author, latest activity and resolution here. Earlier text from edited comments is retained in Comment History.
- key: table:collaboration_comment;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_comment;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_comment;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_comment;field:threadId
summary: "The opening comment of the discussion this reply belongs to."
- key: table:collaboration_comment;field:parentCommentId
summary: "The comment this reply answers."
- key: table:collaboration_comment;field:body
summary: "The text of the comment."
- key: table:collaboration_comment;field:fieldName
summary: "The record field this entry refers to."
- key: table:collaboration_comment;field:authorUserId
summary: "The person who wrote the comment."
- key: table:collaboration_comment;field:lastActivityAt
summary: "When the comment discussion was last active."
- key: table:collaboration_comment;field:editedAt
summary: "When the comment was edited."
- key: table:collaboration_comment;field:resolvedAt
summary: "When the discussion was marked resolved."
- key: table:collaboration_comment;field:resolvedByUserId
summary: "The person who resolved the discussion."
- key: table:collaboration_comment;field:deletedAt
summary: "When the comment was deleted."
- key: table:collaboration_comment;field:deletedByUserId
summary: "The person who deleted the comment."
- key: table:collaboration_comment_revision;slot:help
summary: "Review earlier text saved when comments were edited."
content: |
**How rows get created**
Comment History is filled automatically when someone edits a comment. Each entry identifies the comment, its earlier text, and who edited it and when. Use these records to understand how a discussion changed. An empty list means there are no recorded edits available for you to review.
- key: table:collaboration_comment_revision;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_comment_revision;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_comment_revision;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_comment_revision;field:commentId
summary: "The comment this entry refers to."
- key: table:collaboration_comment_revision;field:body
summary: "The earlier comment text retained for this edit."
- key: table:collaboration_comment_revision;field:editedAt
summary: "When the comment was edited."
- key: table:collaboration_comment_revision;field:editedByUserId
summary: "The person who edited the comment."
- key: table:collaboration_mention;slot:help
summary: "Track people mentioned in comments and whether their notices have been read."
content: |
**How rows get created**
Mention someone in a record comment to create a mention entry. You can review which comment named them, whether the mention is visible, and when they were notified or read it. These entries are maintained by the discussion features; you do not need to create them separately.
- key: table:collaboration_mention;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_mention;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_mention;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_mention;field:commentId
summary: "The comment this entry refers to."
- key: table:collaboration_mention;field:mentionedUserId
summary: "The person mentioned in the comment."
- key: table:collaboration_mention;field:visible
summary: "Whether the mention is visible to the mentioned person."
- key: table:collaboration_mention;field:readAt
summary: "When the mentioned person read the mention."
- key: table:collaboration_mention;field:notifiedAt
summary: "When a notification was sent for the mention."
- key: table:collaboration_follow;slot:help
summary: "See who follows a record and which changes they want to hear about."
content: |
**How rows get created**
Follow a record to receive updates about it where Follow is enabled. Your follower entry stores whether you want notices about changes and comments, along with any temporary mute. You can review why a person follows a record here. Following a record does not replace the permissions needed to read it.
- key: table:collaboration_follow;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_follow;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_follow;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_follow;field:userId
summary: "The person this entry belongs to."
- key: table:collaboration_follow;field:reason
summary: "Why this person follows the record."
- key: table:collaboration_follow;field:notifyOnChange
summary: "Whether the follower wants notifications about record changes."
- key: table:collaboration_follow;field:notifyOnComment
summary: "Whether the follower wants notifications about comments."
- key: table:collaboration_follow;field:mutedUntil
summary: "When the temporary pause in follower notifications ends."
- key: table:collaboration_activity;slot:help
summary: "Review the history of comments, files, tags, tasks and sharing on records."
content: |
**How rows get created**
Activity records what happens as you use collaboration features on a record. Each entry gives the time, the person involved, the kind of event and a short description. Turn on Timeline for the parent table to show this history on records. Activity entries are created automatically and cannot be edited here.
- key: table:collaboration_activity;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_activity;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_activity;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_activity;field:at
summary: "When the activity occurred."
- key: table:collaboration_activity;field:actorUserId
summary: "The person who performed the activity."
- key: table:collaboration_activity;field:type
summary: "The kind of activity recorded."
- key: table:collaboration_activity;field:summary
summary: "A short description of what happened."
- key: table:collaboration_activity;field:refTableName
summary: "The table holding the item referenced by this activity."
- key: table:collaboration_activity;field:refId
summary: "The identifier of the item referenced by this activity."
- key: table:collaboration_follow_digest;slot:help
summary: "Review grouped change notices waiting to be sent to followers."
content: |
**How rows get created**
As followed records change, entries are created automatically. You can review changes waiting to be sent together to someone following a record. Each entry identifies the recipient, the record and the time the notice is due. Send follow digests processes these pending notices. An empty list means no grouped changes are currently waiting here.
- key: table:collaboration_follow_digest;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_follow_digest;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_follow_digest;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_follow_digest;field:userId
summary: "The person this entry belongs to."
- key: table:collaboration_follow_digest;field:dueAt
summary: "When the grouped change notice is due to be sent."
- key: table:collaboration_follow_digest;field:changes
summary: "The changes collected for the next grouped notice."
- key: table:collaboration_tag;slot:help
summary: "Manage the labels people apply to records and their approval status."
content: |
**How rows get created**
Create or suggest a tag from a record to add it to the vocabulary. Use Tags to review the vocabulary available for labeling records. A tag can apply across tables or be limited to one table. Depending on Feature Settings, people can create tags freely or suggest tags for approval. Use Approve tags, Merge tags or Delete tags to manage the vocabulary.
- key: table:collaboration_tag;field:name
summary: "The stable name identifying the tag."
- key: table:collaboration_tag;field:label
summary: "The name shown to you for this entry."
- key: table:collaboration_tag;field:color
summary: "The color used to display the tag."
- key: table:collaboration_tag;field:tableName
summary: "The table this tag is limited to; blank means it is available across tables."
- key: table:collaboration_tag;field:approved
summary: "Whether the tag has been approved for use."
- key: table:collaboration_tag;field:description
summary: "A description that helps you understand this entry."
- key: table:collaboration_tag;field:createdByUserId
summary: "The person who created this entry."
- key: table:collaboration_tag;field:usageCount
summary: "How many records use the tag."
- key: table:collaboration_tag;field:mergedIntoTagId
summary: "The tag that replaced this one after a merge."
- key: table:collaboration_record_tag;slot:help
summary: "See which tags have been applied to each record."
content: |
**How rows get created**
Record Tags links a tag to a record and identifies who applied it. Entries are created when you tag a record or use Add tags on selected records. Tags must be enabled for the parent table. Use the record tagging controls to change these links rather than editing them directly.
- key: table:collaboration_record_tag;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_record_tag;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_record_tag;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_record_tag;field:tagId
summary: "The tag applied to this record."
- key: table:collaboration_record_tag;field:createdByUserId
summary: "The person who created this entry."
- key: table:collaboration_share;slot:help
summary: "Review records shared with people, including messages and opening times."
content: |
**How rows get created**
Share a record with someone from the record header to create a share entry. You can review the sender, recipient, message and the time the recipient opened it. Sharing must be enabled for that table. A share does not itself give the recipient permission to read the record.
- key: table:collaboration_share;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_share;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_share;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_share;field:fromUserId
summary: "The person who shared the record."
- key: table:collaboration_share;field:toUserId
summary: "The person the record was shared with."
- key: table:collaboration_share;field:message
summary: "The message accompanying the share or access request."
- key: table:collaboration_share;field:recipientCouldRead
summary: "Whether the recipient could read the record when it was shared."
- key: table:collaboration_share;field:sentAt
summary: "When the record was shared."
- key: table:collaboration_share;field:openedAt
summary: "When the recipient opened the share."
- key: table:collaboration_access_request;slot:help
summary: "Review requests to open records and record how those requests were handled."
content: |
**How rows get created**
When you cannot open a shared record, you can request access. Approvers review the request and record their decision with Mark granted or Decline. Grant the required permissions separately before marking a request granted; this action only records the outcome. The request keeps the handler, time and any response note.
- key: table:collaboration_access_request;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_access_request;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_access_request;field:requesterUserId
summary: "The person asking for access to the record."
- key: table:collaboration_access_request;field:shareId
summary: "The share associated with this access request."
- key: table:collaboration_access_request;field:message
summary: "The message accompanying the share or access request."
- key: table:collaboration_access_request;field:status
summary: "Whether the request is open, granted, declined or withdrawn."
- key: table:collaboration_access_request;field:handledByUserId
summary: "The person who answered the access request."
- key: table:collaboration_access_request;field:handledAt
summary: "When the access request was answered."
- key: table:collaboration_access_request;field:note
summary: "The response note recorded when the request was handled."
- key: table:collaboration_task;slot:help
summary: "Track record tasks, assignees, due dates, reminders and completion."
content: |
**How rows get created**
Add a task from the Tasks panel on a record where Tasks is enabled. Give it a title and assign it to someone, with a due date or reminder when needed. Use Mark done when the work is complete and Reopen if it needs more work. Reminder times and completion details are recorded here.
- key: table:collaboration_task;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_task;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_task;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_task;field:title
summary: "A short description of the work to do."
- key: table:collaboration_task;field:notes
summary: "Additional details about the task."
- key: table:collaboration_task;field:assigneeUserId
summary: "The person assigned to work on the record or task."
- key: table:collaboration_task;field:dueAt
summary: "When the task is due."
- key: table:collaboration_task;field:remindAt
summary: "When a task reminder should be sent."
- key: table:collaboration_task;field:remindedAt
summary: "When the task reminder was sent."
- key: table:collaboration_task;field:overdueNotifiedAt
summary: "When an overdue task notification was sent."
- key: table:collaboration_task;field:status
summary: "Whether the task is open, done or cancelled."
- key: table:collaboration_task;field:completedAt
summary: "When the task was marked done."
- key: table:collaboration_task;field:completedByUserId
summary: "The person who marked the task done."
- key: table:collaboration_task;field:createdByUserId
summary: "The person who created this entry."
- key: table:collaboration_record_assignment;slot:help
summary: "See who owns each record and who is assigned to work on it."
content: |
**How rows get created**
Record Owners keeps the owner and assignee associated with a record. You make these assignments through the record controls where Assignment is enabled. Ownership and assignment are separate roles, so you can review both here. The entry links back to the parent record and follows its access restrictions.
- key: table:collaboration_record_assignment;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_record_assignment;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_record_assignment;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_record_assignment;field:ownerUserId
summary: "The person responsible for the record."
- key: table:collaboration_record_assignment;field:assigneeUserId
summary: "The person assigned to work on the record or task."
- key: table:collaboration_field_definition;slot:help
summary: "Define additional fields for a table and manage their publication and validation."
content: |
**How rows get created**
Create a definition in Custom Fields to add information to a table without changing its built-in fields. Choose the table, name, label and type, then set any validation or display options. Use {{label:collaborationPublishField}} to activate a draft. Rebuild index and Archive field manage existing active fields. Custom Fields must be enabled on the table.
- key: table:collaboration_field_definition;field:tableName
summary: "The table you are adding the custom field to."
- key: table:collaboration_field_definition;field:name
summary: "The stable name identifying the custom field."
- key: table:collaboration_field_definition;field:label
summary: "The label you see beside the custom field."
- key: table:collaboration_field_definition;field:type
summary: "The kind of value the custom field accepts."
- key: table:collaboration_field_definition;field:options
summary: "The choices available for a selection field, stored as JSON."
- key: table:collaboration_field_definition;field:linkTableName
summary: "The table you can select records from for a record link."
- key: table:collaboration_field_definition;field:required
summary: "Whether you must supply a value for the custom field."
- key: table:collaboration_field_definition;field:defaultValue
summary: "The initial value offered for the custom field."
- key: table:collaboration_field_definition;field:minValue
summary: "The smallest allowed numeric value."
- key: table:collaboration_field_definition;field:maxValue
summary: "The largest allowed numeric value."
- key: table:collaboration_field_definition;field:maxLength
summary: "The maximum number of characters allowed."
- key: table:collaboration_field_definition;field:helpText
summary: "The guidance shown when you fill in the custom field."
- key: table:collaboration_field_definition;field:sectionName
summary: "The section where the custom field is displayed."
- key: table:collaboration_field_definition;field:sortOrder
summary: "The position used to order this entry."
- key: table:collaboration_field_definition;field:filterable
summary: "Whether the custom field is available for filtering."
- key: table:collaboration_field_definition;field:searchable
summary: "Whether the custom field is available for searching."
- key: table:collaboration_field_definition;field:classification
summary: "The sensitivity assigned to information stored in the custom field."
- key: table:collaboration_field_definition;field:status
summary: "Whether the custom field is a draft, active or archived."
content: |
New custom fields start as drafts. Review the definition and use Publish field to make a draft active. Use Archive field when an active definition should no longer be active. You change this status through those actions rather than editing it directly; each action checks the current state before applying the change.
- key: table:collaboration_field_definition;field:indexState
summary: "Whether preparation for filtering is absent, building, ready or failed."
- key: table:collaboration_field_definition;field:storage
summary: "The storage method used for this custom field."
- key: table:collaboration_field_definition;field:statusMessage
summary: "The latest explanation or warning about the custom field."
- key: table:collaboration_field_definition;field:indexDdl
summary: "The database instructions used to prepare the field for filtering."
- key: table:collaboration_field_definition;field:createdByUserId
summary: "The person who created this entry."
- key: table:collaboration_field_value;slot:help
summary: "Review values entered into custom fields on records."
content: |
**How rows get created**
Custom Field Values stores entries made through custom fields on records when separate value storage is used. Each entry identifies its parent record and field, with the value stored according to its type. You enter or change values on the parent record; these supporting records are read-only here.
- key: table:collaboration_field_value;field:tableName
summary: "The table containing the related record."
- key: table:collaboration_field_value;field:recordId
summary: "The identifier of the related record."
- key: table:collaboration_field_value;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_field_value;field:fieldName
summary: "The record field this entry refers to."
- key: table:collaboration_field_value;field:valueText
summary: "The text stored for the custom field."
- key: table:collaboration_field_value;field:valueNumber
summary: "The numeric value stored for the custom field."
- key: table:collaboration_field_value;field:valueDate
summary: "The date stored for the custom field."
- key: table:collaboration_field_value;field:valueDateTime
summary: "The date and time stored for the custom field."
- key: table:collaboration_field_value;field:valueBoolean
summary: "The yes or no value stored for the custom field."
- key: process:collaborationTrimRecents;slot:help
summary: "Remove older Recently Viewed entries according to the configured limits."
content: |
Trim recents maintains Recently Viewed by removing older entries according to the configured per-person count limit. It works on viewing history, so it does not delete the records you opened. Use Recently Viewed to review the retained history after cleanup has run. Automatic cleanup requires your host to configure a scheduler.
- key: process:collaborationBuildFieldIndexes;slot:help
summary: "Prepare active custom fields for filtering."
content: |
Build field indexes performs the background preparation needed for filtering custom fields. You can review Index State and Status Message in Custom Fields to see whether preparation is complete or needs attention. These supporting details help you distinguish a field definition from a field that is ready for filtering.
- key: process:collaborationPublishField;slot:help
summary: "Make selected draft custom fields active."
content: |
Select draft definitions in Custom Fields and use Publish field when their labels, types and validation are ready. Custom Fields must be enabled for the target table. Review the selected definitions before continuing, then read the results for each field; a failed field does not prevent the others from being processed.
- key: process:collaborationArchiveField;slot:help
summary: "Archive selected active custom fields."
content: |
Select active definitions in Custom Fields and use Archive field when they should no longer be active. Review the affected values before continuing. The action checks each selected definition and reports its outcome separately. Read the result for each field so you can distinguish archived definitions from those that could not be changed.
- key: process:collaborationRebuildField;slot:help
summary: "Rebuild filtering support for selected active custom fields."
content: |
Use Rebuild index on active custom fields when their filtering support needs to be prepared again. Review the selected definitions before continuing. After the action, check Index State and Status Message for progress or problems. The result reports each selected field separately, so review any failures before retrying.
- key: process:collaborationSendTaskReminders;slot:help
summary: "Send due task reminders and overdue notifications."
content: |
Send task reminders checks tasks for reminders that are due and work that has become overdue. You choose Due At and Remind At on the task; Reminded At and Overdue Notified At record notices already sent. Review those times when checking why a task has or has not produced a notification.
- key: process:collaborationGrant_collaboration_access_request;slot:help
summary: "Record that selected access requests have been granted."
content: |
Grant the person the required access in your permission system first, then use Mark granted to record the outcome of the request. You can add a Note for the requester. Review the selected requests before continuing. This action records the decision, handler and time; it does not change record permissions.
- key: process:collaborationDecline_collaboration_access_request;slot:help
summary: "Decline selected access requests and record your response."
content: |
Use Decline when an access request should not be approved. You can add a Note explaining the decision before reviewing the selected requests. Only requests you are permitted to handle can be processed. Read the results to confirm which decisions were recorded and which requests were skipped.
- key: process:collaborationDone_collaboration_task;slot:help
summary: "Mark selected tasks done and record their completion."
content: |
Use Mark done after the work described by a task is finished. Review the selected tasks before continuing, then check the results for any tasks you could not change. Completion records who finished the task and when. If more work is needed later, use Reopen on the task.
- key: process:collaborationReopen_collaboration_task;slot:help
summary: "Return selected tasks to open status."
content: |
Use Reopen when a completed task needs more work. Select the tasks and review them before continuing. The action checks whether you can manage each task and reports skipped records separately. Afterward, review the task status and its existing assignment and timing details before continuing the work.
- key: process:collaborationSendFollowDigests;slot:help
summary: "Send grouped change notices that are due for followers."
content: |
Send follow digests processes pending groups of record changes for followers. Each Follow Digests entry identifies the record, recipient and due time. Use that list to review notices waiting to be sent. An empty list means no grouped notices are waiting there; it does not mean the record has no followers.
- key: process:collaborationApproveTags;slot:help
summary: "Approve selected tags for use on records."
content: |
Use Approve tags to make suggested tags available in an approval-based vocabulary. Select the tags you want to approve and review the selection before continuing. You need permission to manage tags. Check the result for skipped or unchanged tags, then review Approved in the Tags list.
- key: process:collaborationMergeTags;slot:help
summary: "Combine selected tags into a chosen target tag."
content: |
Use Merge tags when several tags should be represented by one approved tag. Choose the Target tag and review the selected source tags before continuing. The action moves their record assignments to the target. Review the results and the remaining vocabulary before applying the target tag to more records.
- key: process:collaborationDeleteTags;slot:help
summary: "Delete selected tags and remove their record assignments."
content: |
Use Delete tags when tags should be removed from the vocabulary. Review the selected tags and the records that will lose tag assignments before continuing. You need permission to manage tags. This removes tag assignments from the affected records; it does not delete the records themselves.
- key: process:collaborationDeleteAttachments;slot:help
summary: "Delete selected attachments from their records."
content: |
Select the attachments you want to remove and use Delete attachments. Review the selection before continuing, especially the parent records and file names. The action checks whether you can remove each attachment. Read the results afterward to see which attachments were deleted and which could not be processed.
- key: table:collaboration_feature_setting;section:identity
summary: "The identifying details for this entry."
- key: table:collaboration_feature_setting;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_feature_setting;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_user;section:identity
summary: "The identifying details for this entry."
- key: table:collaboration_user;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_user;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_favorite;section:identity
summary: "The identifying details for this entry."
- key: table:collaboration_favorite;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_favorite;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_recent;section:identity
summary: "The identifying details for this entry."
- key: table:collaboration_recent;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_recent;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_attachment;section:file
summary: "The attached file and its description."
- key: table:collaboration_attachment;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_attachment;section:storage
summary: "The storage details and preparation status for this entry."
- key: table:collaboration_attachment;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_comment;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_comment;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_comment;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_comment_revision;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_comment_revision;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_comment_revision;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_mention;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_mention;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_mention;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_follow;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_follow;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_follow;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_activity;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_activity;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_activity;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_follow_digest;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_follow_digest;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_follow_digest;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_tag;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_tag;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_record_tag;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_record_tag;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_record_tag;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_share;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_share;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_share;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_access_request;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_access_request;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_task;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_task;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_task;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_record_assignment;section:details
summary: "The settings and details associated with this entry."
- key: table:collaboration_record_assignment;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_record_assignment;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_field_definition;section:definition
summary: "The custom field identity, type and publication status."
- key: table:collaboration_field_definition;section:settings
summary: "The custom field choices, validation and display settings."
- key: table:collaboration_field_definition;section:storage
summary: "The storage details and preparation status for this entry."
- key: table:collaboration_field_definition;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_field_value;section:value
summary: "The value entered for the custom field."
- key: table:collaboration_field_value;section:parent
summary: "The record this entry belongs to and whether that record was deleted."
- key: table:collaboration_field_value;section:dates
summary: "When this entry was created and last changed."
- key: table:collaboration_field_definition;section:values
summary: "Review stored values associated with this custom field."
- key: table:collaboration_tag;section:recordTags
summary: "See which records carry this tag."
- key: table:collaboration_comment;section:collaboration_comment_revision
summary: "Review earlier versions of this comment."
- key: table:collaboration_comment;section:collaboration_mention
summary: "See people mentioned in this comment."
- key: table:collaboration_comment;section:collaboration_follow
summary: "See who follows the record containing this comment."
- key: widget:collaborationSetup;slot:label
summary: "See which record features are enabled and where their settings come from."
- key: process:collaborationCreateFeatureSettings;slot:help
summary: "Create editable feature settings from your current configuration."
content: |
Run Create rows from configuration in {{label:collaboration_feature_setting}} when the table is empty. Review the proposed settings before continuing. You need collaboration administrator access.
The action creates the settings already declared by your configuration. It does not overwrite existing rows. After creating rows, edit them to change feature availability; saved settings override the code configuration.
- key: possibleValueSource:collaboration_feature_setting;slot:help
summary: "Control which record features are available across the site, in an app or on a table."
- key: possibleValueSource:collaboration_user;slot:help
summary: "Find the people used in mentions, sharing and assignments."
- key: possibleValueSource:collaboration_favorite;slot:help
summary: "Keep track of the records, tables and apps you have starred."
- key: possibleValueSource:collaboration_recent;slot:help
summary: "Find records you opened recently and see when you last viewed them."
- key: possibleValueSource:collaboration_attachment;slot:help
summary: "Browse files attached to records, with their descriptions and storage details."
- key: possibleValueSource:collaboration_comment;slot:help
summary: "Read record discussions, including replies, authors and resolution details."
- key: possibleValueSource:collaboration_comment_revision;slot:help
summary: "Review earlier text saved when comments were edited."
- key: possibleValueSource:collaboration_mention;slot:help
summary: "Track people mentioned in comments and whether their notices have been read."
- key: possibleValueSource:collaboration_follow;slot:help
summary: "See who follows a record and which changes they want to hear about."
- key: possibleValueSource:collaboration_activity;slot:help
summary: "Review the history of comments, files, tags, tasks and sharing on records."
- key: possibleValueSource:collaboration_follow_digest;slot:help
summary: "Review grouped change notices waiting to be sent to followers."
- key: possibleValueSource:collaboration_tag;slot:help
summary: "Manage the labels people apply to records and their approval status."
- key: possibleValueSource:collaboration_record_tag;slot:help
summary: "See which tags have been applied to each record."
- key: possibleValueSource:collaboration_share;slot:help
summary: "Review records shared with people, including messages and opening times."
- key: possibleValueSource:collaboration_access_request;slot:help
summary: "Review requests to open records and record how those requests were handled."
- key: possibleValueSource:collaboration_task;slot:help
summary: "Track record tasks, assignees, due dates, reminders and completion."
- key: possibleValueSource:collaboration_record_assignment;slot:help
summary: "See who owns each record and who is assigned to work on it."
- key: possibleValueSource:collaboration_field_definition;slot:help
summary: "Define additional fields for a table and manage their publication and validation."
- key: possibleValueSource:collaboration_field_value;slot:help
summary: "Review values entered into custom fields on records."
- key: possibleValueSource:collaboration_field_type;slot:help
summary: "Choose the kind of value your custom field accepts."
- key: possibleValueSource:collaboration_field_status;slot:help
summary: "Choose whether a custom field is a draft, active or archived."
- key: possibleValueSource:collaboration_field_index_state;slot:help
summary: "Review whether filtering support is absent, building, ready or failed."
- key: possibleValueSource:collaboration_field_classification;slot:help
summary: "Choose the sensitivity of information stored in the custom field."
- key: possibleValueSource:collaboration_custom_field_tables;slot:help
summary: "Choose a table configured to support custom fields."
- key: possibleValueSource:collaboration_scope_name;slot:help
summary: "Choose an app or table for the selected scope, or the whole instance."
- key: possibleValueSource:collaboration_record_field_name;slot:help
summary: "Choose a visible field from the selected table."
- key: possibleValueSource:collaboration_access_request_status;slot:help
summary: "Review whether an access request is open, granted, declined or withdrawn."
- key: possibleValueSource:collaboration_task_status;slot:help
summary: "Review whether a task is open, done or cancelled."
- key: possibleValueSource:collaboration_target_type;slot:help
summary: "Choose the kind of destination you want to save."
- key: possibleValueSource:collaboration_scope_type;slot:help
summary: "Choose whether a setting applies to the whole instance, an app or a table."
- key: possibleValueSource:collaboration_feature;slot:help
summary: "Choose the record feature you want to configure."
- key: possibleValueSource:collaboration_permission_mode;slot:help
summary: "Choose whether a feature accepts editing permission or requires an explicit feature permission."
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
version=${project.version}
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -56,7 +57,7 @@ class CollaborationApiContractTest
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
"mvnw.cmd -o -q -Dtest=CollaborationApiContractTest -Dcollaboration.openapi.update=true test")).isEmpty();
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration"));
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration") || name.equals(BasicErrorResponseV1.class.getSimpleName()));
assertThat(model.getPaths().keySet()).allMatch(pathName -> pathName.startsWith("/qqq/v1/qbit/collaboration/"));
assertThat(generated).contains("multipart/form-data", "format: \"binary\"", "clients follow url", "nextCursor");
assertThat(extension.getSpecs()).hasSize(45);
@@ -32,13 +32,17 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -74,7 +78,7 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
MemoryStorage.objects.clear();
MemoryStorage.lastGet = null;
MemoryStorage.fail = false;
config.withAttachmentStorage(new QCodeReference(MemoryStorage.class));
config.withAttachmentStorage(new QCodeReference(MemoryStorage.class)).withUserSessionProvider(new QCodeReference(CarolSessions.class));
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
.withCustomizer(new QCodeReference(TestSessions.class)));
int port;
@@ -166,6 +170,49 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
assertError(400, "POST", "/records/document/1/tasks", "{\"title\":5}", "alice");
}
/*******************************************************************************
** Every payload that carries a user id also carries a display name, resolved in
** one batch per response; ids with no directory entry fall back to themselves.
*******************************************************************************/
@Test
void payloadsCarryDisplayNames() throws Exception
{
assertThat(json(request("GET", "/people/carol", null, "carol"), 200).get("displayName").asText()).isEqualTo("Carol Diaz");
var task = ok("POST", "/records/document/1/tasks", "{\"title\":\"Review\",\"assigneeUserId\":\"carol\"}");
assertThat(task.get("assigneeDisplayName").asText()).isEqualTo("Carol Diaz");
assertThat(task.get("createdByDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(task.get("completedByDisplayName").isNull()).isTrue();
assertThat(ok("GET", "/records/document/1/tasks", null).at("/items/0/assigneeDisplayName").asText()).isEqualTo("Carol Diaz");
assertThat(ok("GET", "/my-work", null).at("/tasks").size()).isZero();
var done = ok("PATCH", "/tasks/" + task.get("id").asInt(), "{\"status\":\"DONE\"}");
assertThat(done.get("completedByDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(json(request("GET", "/tasks/mine?status=DONE", null, "carol"), 200).at("/items/0/createdByDisplayName").asText()).isEqualTo("Alice Morgan");
JsonNode upload = json(upload("alice", "report.txt", "hello", "/records/document/1/attachments"), 200);
assertThat(upload.get("createdByDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(ok("GET", "/records/document/1/attachments", null).at("/items/0/createdByDisplayName").asText()).isEqualTo("Alice Morgan");
JsonNode comment = ok("POST", "/records/document/1/comments", "{\"body\":\"First\"}");
assertThat(comment.get("authorDisplayName").asText()).isEqualTo("Alice Morgan");
ok("PATCH", "/comments/" + comment.get("id").asInt(), "{\"body\":\"Edited\"}");
assertThat(ok("GET", "/comments/" + comment.get("id").asInt() + "/history", null).at("/items/0/editedByDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(ok("GET", "/records/document/1/followers", null).findValuesAsText("userDisplayName")).contains("Alice Morgan");
JsonNode shared = ok("POST", "/records/document/1/shares", "{\"toUserIds\":[\"carol\"],\"message\":\"Look\"}");
assertThat(shared.at("/items/0/fromDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(shared.at("/items/0/toDisplayName").asText()).isEqualTo("Carol Diaz");
JsonNode request = ok("POST", "/access-requests", "{\"tableName\":\"document\",\"recordId\":\"999\",\"message\":\"Please\"}");
assertThat(request.get("requesterDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(request.get("handledByDisplayName").isNull()).isTrue();
assertThat(ok("GET", "/access-requests/mine", null).at("/items/0/requesterDisplayName").asText()).isEqualTo("Alice Morgan");
JsonNode assignment = ok("PUT", "/records/document/1/assignment", "{\"ownerUserId\":\"alice\",\"assigneeUserId\":\"carol\"}");
assertThat(assignment.get("ownerDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(assignment.get("assigneeDisplayName").asText()).isEqualTo("Carol Diaz");
}
/*******************************************************************************
** Presence endpoints share parent checks and return typed merged snapshots.
*******************************************************************************/
@@ -274,6 +321,50 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
assertError(400, "PUT", "/records/document/1/tags", "{\"add\":[1.5]}", "alice");
}
/*******************************************************************************
** The picker sends tag ids through the v1 table query as any JSON number or string.
*******************************************************************************/
@Test
void tagFilterByIdThroughTableQuery() throws Exception
{
RuntimeMetaDataOverlay.refresh(instance);
int a = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Alpha\"}").get("id").asInt();
int b = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Beta\"}").get("id").asInt();
ok("PUT", "/records/document/1/tags", "{\"add\":[" + a + "," + b + "]}");
ok("PUT", "/records/document/2/tags", "{\"add\":[" + a + "]}");
for(String ida : List.of(a + "", "\"" + a + "\"", a + ".0"))
{
assertThat(tagQuery("IN", "[" + ida + "]")).containsExactlyInAnyOrder(1, 2);
assertThat(tagQuery("EQUALS", "[" + ida + "]")).containsExactlyInAnyOrder(1, 2);
assertThat(tagQuery("NOT_IN", "[" + ida + "]")).isEmpty();
}
for(String idb : List.of(b + "", "\"" + b + "\"", b + ".0"))
{
assertThat(tagQuery("CONTAINS_ALL", "[" + a + "," + idb + "]")).containsExactly(1);
assertThat(tagQuery("NOT_IN", "[" + idb + "]")).containsExactly(2);
}
assertThat(tagQuery("IN", "[\"alpha\"]")).containsExactlyInAnyOrder(1, 2);
}
/*******************************************************************************
** Run the v1 table query filtered on the virtual tags field; return matching ids.
*******************************************************************************/
private List<Integer> tagQuery(String operator, String values) throws Exception
{
String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/"));
String body = "{\"filter\":{\"criteria\":[{\"fieldName\":\"collaborationTags\",\"operator\":\"" + operator + "\",\"values\":" + values + "}],\"orderBys\":[{\"fieldName\":\"id\"}]}}";
HttpResponse<String> response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/query")).timeout(Duration.ofSeconds(20))
.header("Cookie", "sessionUUID=alice" + sessionSuffix).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
JsonNode result = json(response, 200);
List<Integer> ids = new java.util.ArrayList<>();
for(JsonNode record : result.get("records"))
{
ids.add(record.at("/values/id").asInt());
}
return (ids);
}
/*******************************************************************************
** Comments, revisions, mentions, follow settings and the merged timeline.
*******************************************************************************/
@@ -300,12 +391,17 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isTrue();
ok("POST", "/comments/" + id + "/reopen", null);
assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isFalse();
JsonNode people = ok("GET", "/people?q=alice", null);
assertThat(people.at("/items/0/userId").asText()).isEqualTo("alice");
assertThat(ok("GET", "/people/alice", null).get("displayName").asText()).isEqualTo("alice");
JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=alice", null).at("/items/0");
assertThat(candidate.at("/person/userId").asText()).isEqualTo("alice");
assertThat(candidate.get("canRead").asText()).isEqualTo("unknown");
assertThat(json(request("GET", "/people/carol", null, "carol"), 200).get("displayName").asText()).isEqualTo("Carol Diaz");
JsonNode people = ok("GET", "/people?q=carol", null);
assertThat(people.at("/items/0/userId").asText()).isEqualTo("carol");
assertThat(ok("GET", "/people?q=alice", null).get("items")).as("the caller is not offered to themself").isEmpty();
assertThat(ok("GET", "/people", null).findValuesAsText("userId")).doesNotContain("alice").contains("carol");
assertThat(ok("GET", "/people/alice", null).get("displayName").asText()).isEqualTo("Alice Morgan");
assertThat(ok("GET", "/mentions/candidates?table=document&pk=1&q=alice", null).get("items")).isEmpty();
assertThat(ok("GET", "/mentions/candidates?table=document&pk=1", null).findValuesAsText("userId")).doesNotContain("alice");
JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=carol", null).at("/items/0");
assertThat(candidate.at("/person/userId").asText()).isEqualTo("carol");
assertThat(candidate.get("canRead").asText()).isEqualTo("true");
assertThat(ok("GET", "/records/document/1/follow", null).at("/follow/reason").asText()).isEqualTo("COMMENTED");
JsonNode state = ok("PUT", "/records/document/1/follow", "{\"notifyOnChange\":false,\"notifyOnComment\":true}");
assertThat(state.at("/follow/notifyOnChange").asBoolean()).isFalse();
@@ -336,8 +432,16 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
HttpResponse<String> hidden = request("GET", "/records/document/3/" + feature, null, "alice");
HttpResponse<String> absent = request("GET", "/records/document/999/" + feature, null, "alice");
assertThat(hidden.statusCode()).isEqualTo(404);
assertThat(hidden.body()).isEqualTo(absent.body());
assertThat(json(hidden, 404).get("error").asText()).isEqualTo("Not found");
ObjectNode hiddenError = (ObjectNode) json(hidden, 404);
ObjectNode absentError = (ObjectNode) json(absent, 404);
assertThat(hiddenError.path("traceId").asText()).isNotBlank();
assertThat(absentError.path("traceId").asText()).isNotBlank();
hiddenError.remove("traceId");
absentError.remove("traceId");
assertThat(hiddenError).isEqualTo(absentError);
assertThat(hiddenError.path("error").asText()).isEqualTo("Not found");
assertThat(hiddenError.path("code").asText()).isEqualTo("NOT_FOUND");
assertThat(hiddenError.path("message").asText()).isEqualTo("Not found");
}
assertError(404, "POST", "/records/document/3/comments", "not json", "alice");
assertError(404, "GET", "/mentions/candidates?table=document&pk=3", null, "alice");
@@ -405,15 +509,19 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
JsonNode version = json(client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/qqq/v1/version")).GET().build(),
HttpResponse.BodyHandlers.ofString()), 200);
assertThat(version.at("/extensions/0/name").asText()).isEqualTo("collaboration");
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.0.0");
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.1.0");
}
/*******************************************************************************
** Framework errors always contain a human-readable error property.
** Framework errors include the standard message, code and request trace.
*******************************************************************************/
private void assertError(int status, String method, String path, String body, String caller) throws Exception
{
assertThat(json(request(method, path, body, caller), status).get("error").asText()).isNotBlank();
var error = json(request(method, path, body, caller), status);
assertThat(error.path("error").asText()).isNotBlank();
assertThat(error.path("code").asText()).isNotBlank();
assertThat(error.path("message").asText()).isNotBlank();
assertThat(error.path("traceId").asText()).isNotBlank();
}
/*******************************************************************************
@@ -439,15 +547,33 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
public void customizeSession(QInstance qInstance, QSession session, Map<String, Object> context)
{
String caller = session.getUuid().split("-")[0];
session.withUser(new QUser().withIdReference(caller).withFullName(caller));
session.withUser(new QUser().withIdReference(caller).withFullName(switch(caller)
{
case "alice" -> "Alice Morgan";
case "carol" -> "Carol Diaz";
default -> caller;
}));
session.withPermissions("document.read").withSecurityKeyValue("tenant", caller.equals("outsider") ? "b" : "a");
if(caller.equals("alice"))
if(caller.equals("alice") || caller.equals("carol"))
{
session.withPermissions("document.write");
}
}
}
/*******************************************************************************
** Carol is the one other person who can open the shared documents.
*******************************************************************************/
public static class CarolSessions implements UserSessionProviderInterface
{
@Override
public Optional<QSession> sessionForUser(String userId)
{
return (Optional.ofNullable("carol".equals(userId) ? new QSession().withUser(new QUser().withIdReference("carol")).withPermissions("document.read", "document.write")
.withSecurityKeyValue("tenant", "a") : null));
}
}
/*******************************************************************************
** Public no-arg adapter loaded through host configuration, with in-memory bytes.
*******************************************************************************/
@@ -0,0 +1,62 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Coverage of metadata produced by this qbit, before core adds its own bulk actions.
*******************************************************************************/
class CollaborationHelpBundleTest
{
/*******************************************************************************
** Every owned object resolves and has help, without host-specific providers.
*******************************************************************************/
@Test
void bundleCoversProducedMetadata() throws Exception
{
QInstance instance = new QInstance();
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory")).produce(instance);
HelpBundleAssertions.forInstance(instance)
.bundles("help/qbit-collaboration.yaml")
.owning(target -> target.name().startsWith("collaboration"))
.assertComplete();
var entries = HelpBundles.of(instance).getBundles().getFirst().getEntries();
assertThat(entries).extracting(entry -> entry.getKey()).doesNotHaveDuplicates();
// Core's gate requires tables, fields, apps and processes; also cover our widgets and selectors.
instance.getWidgets().keySet().forEach(name -> assertThat(entries)
.anyMatch(entry -> entry.getKey().equals("widget:" + name + ";slot:label")));
instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("collaboration")).forEach(name -> assertThat(entries)
.anyMatch(entry -> entry.getKey().equals("possibleValueSource:" + name + ";slot:help")));
}
}
@@ -0,0 +1,223 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
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.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
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 collaboration_* table:
** - runtime code (not just its model, metadata or home screen) reads or writes
** it, so no table exists only on paper;
** - it is either seeded (feature settings: FeatureSettingsDefaults, from the
** configuration at run time) or listed here with why it starts empty;
** - it has bundle help that says what it is for and how its rows get created.
** A new table fails this test until it is wired, seeded or explained.
*******************************************************************************/
class CollaborationTablesGuardrailTest extends PersonalServiceFixture
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "collaboration");
/*******************************************************************************
** Seeded from the code configuration by "Create rows from configuration".
*******************************************************************************/
static final List<String> SEEDED = List.of("collaboration_feature_setting");
/*******************************************************************************
** Tables that legitimately start empty, and where their rows come from.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
Map.entry("collaboration_user", "runtime: filled as signed-in people use collaboration services"),
Map.entry("collaboration_favorite", "user content: a person stars something"),
Map.entry("collaboration_recent", "runtime: a person opens a record"),
Map.entry("collaboration_attachment", "user content: a person uploads a file"),
Map.entry("collaboration_comment", "user content: a person comments"),
Map.entry("collaboration_comment_revision", "runtime: a comment is edited"),
Map.entry("collaboration_mention", "runtime: a comment mentions someone"),
Map.entry("collaboration_follow", "user content: a person follows a record"),
Map.entry("collaboration_follow_digest", "runtime: a followed record changes"),
Map.entry("collaboration_activity", "runtime: collaboration features are used"),
Map.entry("collaboration_tag", "user content: a person or administrator adds a tag"),
Map.entry("collaboration_record_tag", "user content: a person tags a record"),
Map.entry("collaboration_field_definition", "administrator content: a field manager defines a custom field"),
Map.entry("collaboration_field_value", "user content: a person fills in a custom field"),
Map.entry("collaboration_share", "user content: a person shares a record"),
Map.entry("collaboration_access_request", "user content: a person asks for access"),
Map.entry("collaboration_task", "user content: a person adds a task"),
Map.entry("collaboration_record_assignment", "user content: a person assigns a record"));
/*******************************************************************************
** Every table is used by runtime code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
Map<String, String> constantByTable = constantByTable();
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("app"))
&& !p.getFileName().toString().equals("CoreCollaboration.java") && !p.getFileName().toString().equals("CollaborationSchema.java"))
.map(CollaborationTablesGuardrailTest::read).toList();
}
for(String table : collaborationTables())
{
String constant = constantByTable.get(table);
assertThat(constant).as("constant for " + table).isNotNull();
Pattern use = Pattern.compile("CollaborationTables\\." + constant + "\\b");
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
.as(table + " (" + constant + ") must be read or written by runtime code, not only declared").isTrue();
}
}
/*******************************************************************************
** Every table is seeded or explained.
*******************************************************************************/
@Test
void everyTableIsSeededOrExplained() throws Exception
{
for(String table : collaborationTables())
{
boolean seeded = SEEDED.contains(table);
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded 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(STARTS_EMPTY.keySet()).allMatch(table -> instance.getTable(table) != null, "STARTS_EMPTY names only real tables");
///////////////////////////////////////////////////////////////////////////////////////
// the seeded table has a real action that writes its rows, and it is named in help //
///////////////////////////////////////////////////////////////////////////////////////
assertThat(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getTableName()).isEqualTo("collaboration_feature_setting");
assertThat(FeatureSettingsDefaults.configuredRows(instance, config)).isNotEmpty();
assertThat(help("collaboration_feature_setting").getContent()).contains(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getLabel());
}
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
for(String table : collaborationTables())
{
assertThat(help(table).getContent()).as(table + " help says how rows get created").contains("How rows get created");
}
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-collaboration");
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CollaborationQBitProducer.VERSION);
assertThat(bundle.getEntries()).anyMatch(entry -> entry.getKey().equals("app:collaboration;slot:help"));
});
}
/*******************************************************************************
** Registration works without a help table; core owns the guarded startup sync.
*******************************************************************************/
@Test
void registrationWithoutHelpTable()
{
assertThat(instance.getTable("helpContent")).isNull();
HelpBundles.syncOnStartup(instance);
assertThat(HelpBundles.of(instance).getBundles()).hasSize(1);
}
/*******************************************************************************
**
*******************************************************************************/
private HelpBundleEntry help(String table)
{
return (HelpBundles.of(instance).getBundles().getFirst().getEntries().stream()
.filter(entry -> entry.getKey().equals("table:" + table + ";slot:help")).findFirst().orElseThrow());
}
/*******************************************************************************
**
*******************************************************************************/
private List<String> collaborationTables()
{
List<String> tables = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("collaboration_")).sorted().toList();
assertThat(tables).hasSize(19);
return (tables);
}
/*******************************************************************************
** CollaborationTables declares X = "collaboration_x".
*******************************************************************************/
private static Map<String, String> constantByTable() throws Exception
{
Map<String, String> rs = new HashMap<>();
Matcher m = Pattern.compile("String\\s+([A-Z_]+)\\s*=\\s*\"(collaboration_[a-z_]+)\"").matcher(read(MAIN.resolve("model").resolve("CollaborationTables.java")));
while(m.find())
{
rs.put(m.group(2), m.group(1));
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -23,7 +23,6 @@ package com.kingsrook.qbits.collaboration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
@@ -40,7 +39,8 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
@@ -115,8 +115,17 @@ class CollaborationTest
assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12);
assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName())
.containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET);
assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull();
assertThat(instance.getApp("collaboration").getWidgets()).containsExactlyElementsOf(CollaborationHome.HOME_WIDGETS);
for(String widget : CollaborationHome.HOME_WIDGETS)
{
assertThat(instance.getWidget(widget)).as(widget).isNotNull();
assertThat(instance.getWidget(widget).getGridColumns()).isEqualTo(12);
assertThat(instance.getWidget(widget).getIsCard()).isFalse();
}
assertThat(instance.getWidget(CollaborationHome.KPI_WIDGET).getType()).isEqualTo("multiStatistics");
assertThat(instance.getWidget(CollaborationHome.TAGS_WIDGET).getType()).isEqualTo("table");
assertThat(instance.getWidget(CollaborationHome.FIELDS_WIDGET).getType()).isEqualTo("table");
assertThat(instance.getWidget(CollaborationHome.PRESENCE_WIDGET).getType()).isEqualTo("table");
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getUniqueKeys().getFirst().getFieldNames()).containsExactly("scopeType", "scopeName", "feature");
assertThat(instance.getTable(CollaborationTables.USER).getUniqueKeys().getFirst().getFieldNames()).containsExactly("userId");
@@ -137,7 +146,7 @@ class CollaborationTest
assertThat(producer.isEnabled()).isTrue();
assertThat(producer.getQBitConfig()).isSameAs(config);
assertThat(producer.getNamespace()).isEqualTo("collaboration");
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo("0.1.0");
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CollaborationQBitProducer.VERSION);
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-collaboration");
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("memory");
assertThat(config.getAdminApp()).isTrue();
@@ -168,6 +177,41 @@ class CollaborationTest
/*******************************************************************************
** Without a parent app, Collaboration is a top-level node of the frontend app tree.
*******************************************************************************/
@Test
void appTreeTopLevelWithoutParent() throws Exception
{
new QInstanceValidator().validate(instance);
var output = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction().execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput());
assertThat(output.getApps()).containsKey("collaboration");
assertThat(output.getAppTree()).extracting(node -> node.getName()).contains("collaboration");
}
/*******************************************************************************
** With a parent app, Collaboration is nested under it and not duplicated at the top.
*******************************************************************************/
@Test
void appTreeNestedUnderParent() throws Exception
{
instance = new QInstance();
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
instance.setEnforcePermissionsInCoreActions(true);
instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin").withIcon(new QIcon("settings")));
new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory").withParentAppName("admin")).produce(instance);
QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("admin")).withPermissions(CollaborationPermissions.ADMIN));
new QInstanceValidator().validate(instance);
var output = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction().execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput());
assertThat(output.getAppTree()).extracting(node -> node.getName()).containsExactly("admin");
assertThat(output.getAppTree().getFirst().getChildren()).extracting(node -> node.getName()).contains("collaboration");
}
/*******************************************************************************
** Headless hosts keep the tables without app or widgets.
*******************************************************************************/
@@ -185,34 +229,79 @@ class CollaborationTest
/*******************************************************************************
** Empty states explain what has not been populated.
** Home tables leave column widths to the frontend, so narrow cards wrap
** instead of scrolling sideways (issue #21).
*******************************************************************************/
@Test
void emptyHome() throws Exception
void homeTablesHaveNoFixedColumnWidths() throws Exception
{
String html = CollaborationHome.render(Instant.now());
assertThat(html).contains("No feature settings yet: features follow the code defaults", "People known: 0", "Active in the last 7 days: 0", "No people yet");
RawHTML widget = (RawHTML) new CollaborationHome.HomeRenderer().render(new RenderWidgetInput()).getWidgetData();
assertThat(widget.getHtml()).contains("People known: 0");
for(TableData table : java.util.List.of(CollaborationHome.tags(), CollaborationHome.fields(), CollaborationHome.presence()))
{
assertThat(table.getColumns()).isNotEmpty().allSatisfy(column -> assertThat(column.getWidth()).isNull());
}
}
/*******************************************************************************
** Counts distinguish disabled settings and the inclusive seven-day window.
** Empty states explain what has not been populated.
*******************************************************************************/
@Test
void realCounts() throws Exception
void emptyHome() throws Exception
{
Instant now = Instant.parse("2026-10-13T12:00:00Z");
seed(CollaborationTables.FEATURE_SETTING,
setting("INSTANCE", "instance", "COMMENTS", true), setting("APP", "sales", "TAGS", true),
setting("TABLE", "orders", "TASKS", true), setting("TABLE", "orders", "COMMENTS", true),
setting("TABLE", "orders", "PRESENCE", false));
seed(CollaborationTables.USER, person("one", now), person("boundary", now.minus(7, ChronoUnit.DAYS)),
person("old", now.minus(7, ChronoUnit.DAYS).minusSeconds(1)), person("never", null), person("future", now.plusSeconds(1)));
String html = CollaborationHome.render(now);
assertThat(html).contains("INSTANCE: 1", "APP: 1", "TABLE: 2", "People known: 5", "Active in the last 7 days: 2").doesNotContain("No feature settings yet", "No people yet");
assertThat(HomeWidgetData.stats(Instant.now())).containsExactly(
java.util.Map.entry("Comments this week", 0), java.util.Map.entry("Open threads", 0), java.util.Map.entry("Open tasks", 0),
java.util.Map.entry("Overdue tasks", 0), java.util.Map.entry("Open access requests", 0), java.util.Map.entry("Attachments", 0),
java.util.Map.entry("Active custom fields", 0));
assertThat(CollaborationHome.tags().getRows()).isEmpty();
assertThat(CollaborationHome.tags().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_TAGS);
assertThat(CollaborationHome.fields().getRows()).isEmpty();
assertThat(CollaborationHome.fields().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_FIELDS);
assertThat(CollaborationHome.presence().getRows()).isEmpty();
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo("Nobody else is here right now.");
assertThat(((TableData) new CollaborationHome.PresenceRenderer().render(new RenderWidgetInput()).getWidgetData()).getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NOBODY_HERE);
assertThat(new CollaborationHome.KpiRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(MultiStatisticsData.class);
assertThat(new CollaborationHome.TagsRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(TableData.class);
assertThat(new CollaborationHome.FieldsRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(TableData.class);
assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Work")).isEqualTo("Nothing waiting");
}
/*******************************************************************************
** Display names resolve in one query; unknown, blank and null ids stay readable.
*******************************************************************************/
@Test
void displayNamesResolveInBatches() throws Exception
{
seed(CollaborationTables.USER, new QRecord().withValue("userId", "ann").withValue("displayName", "Ann Lee").withValue("active", true),
new QRecord().withValue("userId", "nameless").withValue("displayName", " ").withValue("active", true));
var names = new com.kingsrook.qbits.collaboration.people.PeopleService().displayNames(java.util.Arrays.asList("ann", "nameless", "ghost", null, "", "ann"));
assertThat(names).containsExactly(java.util.Map.entry("ann", "Ann Lee"), java.util.Map.entry("nameless", "nameless"), java.util.Map.entry("ghost", "ghost"));
assertThat(new com.kingsrook.qbits.collaboration.people.PeopleService().displayNames(List.of())).isEmpty();
assertThat(instance.getTable(CollaborationTables.TASK).getField("assigneeUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
assertThat(instance.getTable(CollaborationTables.COMMENT).getField("authorUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
assertThat(instance.getTable(CollaborationTables.ATTACHMENT).getField("createdByUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
assertThat(instance.getTable(CollaborationTables.USER).getField("userId").getPossibleValueSourceName()).isNull();
assertThat(instance.getPossibleValueSource(CollaborationTables.USER).getOverrideIdField()).isEqualTo("userId");
}
/*******************************************************************************
** Tags and custom fields list real rows with table labels, All tables for global ones.
*******************************************************************************/
@Test
void tablesListRealRows() throws Exception
{
seed(CollaborationTables.TAG,
new QRecord().withValue("name", "global").withValue("label", "Global").withValue("approved", true).withValue("createdByUserId", "admin").withValue("usageCount", 3),
new QRecord().withValue("name", "popular").withValue("label", "Popular").withValue("approved", true).withValue("createdByUserId", "admin").withValue("usageCount", 5));
var tags = CollaborationHome.tags();
assertThat(tags.getColumns()).extracting(TableData.Column::getHeader).containsExactly("Label", "Table", "Records");
assertThat(tags.getRows()).hasSize(2);
assertThat(tags.getRows().get(0)).containsEntry("label", "Popular").containsEntry("table", "All tables").containsEntry("records", 5);
assertThat(tags.getRows().get(1)).containsEntry("label", "Global").containsEntry("table", "All tables").containsEntry("records", 3);
}
@@ -225,7 +314,7 @@ class CollaborationTest
{
QContext.getQSession().setPermissions(new java.util.HashSet<>());
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.USER))).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> CollaborationHome.render(Instant.now())).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> CollaborationHome.kpis(Instant.now())).isInstanceOf(QPermissionDeniedException.class);
QContext.getQSession().withPermissions(CollaborationPermissions.VIEW);
new QueryAction().execute(new QueryInput(CollaborationTables.FEATURE_SETTING));
new QueryAction().execute(new QueryInput(CollaborationTables.USER));
@@ -252,14 +341,16 @@ class CollaborationTest
void homeUsesCurrentSession() throws Exception
{
Instant now = Instant.now();
seed(CollaborationTables.USER, person("visible", now), person("hidden", now));
seed(CollaborationTables.TAG, new QRecord().withValue("name", "visible").withValue("label", "Visible").withValue("approved", true).withValue("createdByUserId", "admin"),
new QRecord().withValue("name", "hidden").withValue("label", "Hidden").withValue("approved", true).withValue("createdByUserId", "admin"));
instance.addSecurityKeyType(new QSecurityKeyType().withName("person").withAllAccessKeyName("allPeople"));
instance.getTable(CollaborationTables.USER).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("person").withFieldName("userId"));
instance.getTable(CollaborationTables.TAG).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("person").withFieldName("name"));
QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("viewer")).withPermissions(CollaborationPermissions.VIEW).withSecurityKeyValue("person", "visible"));
assertThat(CollaborationHome.render(now)).contains("People known: 1", "Active in the last 7 days: 1");
assertThat(CollaborationHome.tags().getRows()).singleElement().satisfies(row -> assertThat(row).containsEntry("label", "Visible"));
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY);
instance.setEnforcePermissionsInCoreActions(false);
QContext.getQSession().setPermissions(new java.util.HashSet<>());
assertThatThrownBy(() -> new CollaborationHome.HomeRenderer().render(new RenderWidgetInput())).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> new CollaborationHome.KpiRenderer().render(new RenderWidgetInput())).isInstanceOf(QPermissionDeniedException.class);
}
@@ -347,11 +347,15 @@ public class ConversationServicesTest extends PersonalServiceFixture
new PeopleService(Clock.fixed(at, ZoneOffset.UTC)).touch();
QContext.getQSession().getUser().setFullName("Changed Alice");
new PeopleService(Clock.fixed(at.plusSeconds(599), ZoneOffset.UTC)).touch();
assertThat(new PeopleService().search("Alice", 5)).singleElement().satisfies(p -> assertThat(p.displayName()).isEqualTo("Alice Example"));
new PeopleService(Clock.fixed(at.plusSeconds(600), ZoneOffset.UTC)).touch();
assertThat(new PeopleService().search("changed", 5)).hasSize(1);
assertThat(new PeopleService().search("Alice", 5)).as("the caller never finds themself").isEmpty();
login("bob");
new PeopleService().touch();
assertThat(new PeopleService().search("Alice", 5)).singleElement().satisfies(p -> assertThat(p.displayName()).isEqualTo("Alice Example"));
login("alice");
QContext.getQSession().getUser().setFullName("Changed Alice");
new PeopleService(Clock.fixed(at.plusSeconds(600), ZoneOffset.UTC)).touch();
login("bob");
assertThat(new PeopleService().search("changed", 5)).hasSize(1);
follows.follow("document", 1, null, null);
login("eve");
new PeopleService().touch();
@@ -507,8 +511,7 @@ public class ConversationServicesTest extends PersonalServiceFixture
assertThat(sql).contains("ON collaboration_comment (table_name, record_id, create_date)", "ON collaboration_follow_digest (due_at)");
comments.post("document", 1, "home", null, null);
admin();
String html = com.kingsrook.qbits.collaboration.app.CollaborationHome.render(Instant.now().plusSeconds(1));
assertThat(html).contains("Comments this week</h3><p>1", "Open threads</h3><p>1", "Followers</h3><p>1");
assertThat(HomeWidgetData.stats(Instant.now().plusSeconds(1))).containsEntry("Comments this week", 1).containsEntry("Open threads", 1);
assertThatThrownBy(() -> config.withFollowActionTables(List.of("missing")).validate(instance)).hasMessageContaining("host table");
}
@@ -48,6 +48,8 @@ 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.fields.JsonPathStorage;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -57,6 +59,50 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*******************************************************************************/
public class CustomFieldsTest extends PersonalServiceFixture
{
/*******************************************************************************
** Refreshing a SELECT must replace its generated vocabulary and allow later fields.
*******************************************************************************/
@Test
void publishedSelectSurvivesRefreshAndOptionChanges() throws Exception
{
admin();
QRecord choice = draft("contact", "SELECT");
new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
new QRecord().withValue("id", choice.getValue("id")).withValue("options", "[{\"id\":\"email\",\"label\":\"Email\"}]"))));
FieldDefinitions.apply(choice.getValue("id"), "Publish");
QRecord reference = draft("reference", "STRING");
FieldDefinitions.apply(reference.getValue("id"), "Publish");
assertThat(instance.getTable("document").getFields()).containsKeys("cf_contact", "cf_reference");
new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
new QRecord().withValue("id", choice.getValue("id")).withValue("options", "[{\"id\":\"email\",\"label\":\"Email\"},{\"id\":\"phone\",\"label\":\"Phone\"}]"))));
RuntimeMetaDataOverlay.refresh(instance);
String source = instance.getTable("document").getField("cf_contact").getPossibleValueSourceName();
assertThat(instance.getPossibleValueSource(source).getEnumValues()).hasSize(2);
assertThat(instance.getTable("document").getFields()).containsKeys("cf_contact", "cf_reference");
}
/*******************************************************************************
** The home lists definitions with table label, status and index state, and counts active ones.
*******************************************************************************/
@Test
void homeListsDefinitionsAndCountsActiveOnes() throws Exception
{
admin();
QRecord definition = draft("region", "STRING");
var drafts = CollaborationHome.fields();
assertThat(drafts.getColumns()).extracting(TableData.Column::getHeader).containsExactly("Name", "Table", "Status", "Index state");
assertThat(drafts.getRows()).singleElement().satisfies(row -> assertThat(row).containsEntry("name", "region").containsEntry("table", "Documents")
.containsEntry("status", "Draft").containsEntry("indexState", "None"));
assertThat(HomeWidgetData.stats(java.time.Instant.now())).containsEntry("Active custom fields", 0);
FieldDefinitions.apply(definition.getValue("id"), "Publish");
assertThat(CollaborationHome.fields().getRows().getFirst()).containsEntry("status", "Active");
assertThat(HomeWidgetData.stats(java.time.Instant.now())).containsEntry("Active custom fields", 1);
}
/*******************************************************************************
** Create a real draft through the permission and pre-insert customizers.
*******************************************************************************/
@@ -133,8 +179,9 @@ public class CustomFieldsTest extends PersonalServiceFixture
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(CollaborationTables.FIELD_DEFINITION).withPrimaryKeys(List.of(row.getValue("id"))))).hasMessageContaining("archive");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("name", "renamed"), row)).hasMessageContaining("cannot change");
assertThatThrownBy(() -> FieldDefinitions.options(new QRecord().withValue("options", "{}"))).hasMessageContaining("options");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("classification", "BAD"), row)).hasMessageContaining("classification");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("minValue", 5).withValue("maxValue", 1), row)).hasMessageContaining("Minimum");
QRecord published = FieldDefinitions.get(row.getValue("id"));
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(published).withValue("classification", "BAD"), published)).hasMessageContaining("classification");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(published).withValue("minValue", 5).withValue("maxValue", 1), published)).hasMessageContaining("Minimum");
}
/*******************************************************************************
@@ -0,0 +1,398 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Consumer;
import com.kingsrook.qbits.collaboration.app.CollaborationSetupStatus;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
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.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
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.collaboration.FeaturePermissionMode;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
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.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The "Create rows from configuration" defaults for collaboration_feature_setting.
** Main claim: writing the rows changes nothing. For several configurations
** (default, table features, app and table declarations, permission modes,
** settings, two apps containing one table), every table resolves to exactly the
** same enabled flag, permission mode and settings with and without the rows.
*******************************************************************************/
class FeatureSettingsDefaultsTest
{
private QInstance instance;
private CollaborationQBitConfig config;
/*******************************************************************************
** Fresh instance: a backend, three host tables, two apps, then the qbit.
** `code` runs after the qbit is produced and declares features on apps/tables.
*******************************************************************************/
private void build(Consumer<CollaborationQBitConfig> configure, Consumer<QInstance> code) throws Exception
{
MemoryRecordStore.getInstance().reset();
instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
instance.setEnforcePermissionsInCoreActions(true);
for(String name : List.of("document", "invoice", "receipt"))
{
instance.addTable(new QTableMetaData().withName(name).withLabel(name.substring(0, 1).toUpperCase() + name.substring(1) + "s").withBackendName("memory").withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("title", QFieldType.STRING)).withRecordLabelFormatAndFields("%s", "title"));
}
instance.addApp(new QAppMetaData().withName("sales").withLabel("Sales").withIcon(new QIcon("sell")).withSection(new QAppSection().withName("s").withLabel("S").withTables(List.of("invoice", "receipt"))));
instance.addApp(new QAppMetaData().withName("ops").withLabel("Ops").withIcon(new QIcon("build")).withSection(new QAppSection().withName("o").withLabel("O").withTables(List.of("receipt", "document"))));
config = new CollaborationQBitConfig().withBackendName("memory");
configure.accept(config);
new CollaborationQBitProducer().withQBitConfig(config).produce(instance);
code.accept(instance);
new com.kingsrook.qqq.backend.core.instances.QInstanceEnricher(instance).enrich();
admin();
RuntimeMetaDataOverlay.refresh(instance);
}
/*******************************************************************************
**
*******************************************************************************/
private void admin()
{
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("admin")).withPermissions(CollaborationPermissions.ADMIN));
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void tearDown()
{
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.close(instance);
com.kingsrook.qqq.backend.core.realtime.RealtimeBus.close(instance);
QContext.clear();
MemoryRecordStore.getInstance().reset();
}
/*******************************************************************************
** What every host table resolves to for every feature: enabled, permission mode,
** settings (with the TAGS mode as TagsService reads it).
*******************************************************************************/
private Map<String, String> effective()
{
Map<String, String> rs = new TreeMap<>();
for(String table : List.of("document", "invoice", "receipt"))
{
var resolved = RecordFeaturesResolver.resolve(instance, instance.getTable(table));
for(RecordFeature feature : RecordFeature.values())
{
var one = resolved.getFeatures().get(feature);
Map<String, java.io.Serializable> settings = new TreeMap<>(one.getSettings());
if(feature == RecordFeature.TAGS)
{
///////////////////////////////////////////////////////////////////////////
// TagsService reads a missing mode as the configured default tag mode, //
// so a row that spells the default out is the same effective setting. //
///////////////////////////////////////////////////////////////////////////
settings.putIfAbsent("mode", config.getDefaultTagMode());
}
rs.put(table + "." + feature, one.getEnabled() + " " + one.getPermissionMode() + " " + settings);
}
}
return (rs);
}
/*******************************************************************************
** Run the real "Create rows from configuration" process end to end.
*******************************************************************************/
@SuppressWarnings("unchecked")
private List<ProcessSummaryLine> create() throws Exception
{
RunProcessInput input = new RunProcessInput();
input.setProcessName(FeatureSettingsDefaultsProcess.NAME);
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
RunProcessOutput output = new RunProcessAction().execute(input);
return ((List<ProcessSummaryLine>) output.getValues().get(com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY));
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> rows() throws Exception
{
return (new QueryAction().execute(new QueryInput(CollaborationTables.FEATURE_SETTING).withFilter(new QQueryFilter())).getRecords());
}
/*******************************************************************************
** The configurations: defaults; table features with GRANTED_ONLY and a table
** TAGS mode; an app that turns attachments on and sets the sharing approver;
** a different default tag mode and default features; two apps with one table.
*******************************************************************************/
private void scenario(int number) throws Exception
{
switch(number)
{
case 0 -> build(c -> { }, i -> { });
case 1 -> build(c -> c.withDefaultFeatures(RecordFeature.COMMENTS, RecordFeature.TAGS).withTableFeatures("document", RecordFeature.TAGS, RecordFeature.TASKS), i ->
{
RecordFeaturesMetaData.ofOrWithNew(i.getTable("invoice")).withPermissionMode(RecordFeature.SHARING, FeaturePermissionMode.GRANTED_ONLY)
.withSetting(RecordFeature.TAGS, "mode", "APPROVED_ONLY");
});
case 2 -> build(c -> c.withDefaultTagMode("APPROVED_ONLY"), i ->
{
RecordFeaturesMetaData.ofOrWithNew(i.getApp("sales")).withEnabled(RecordFeature.ATTACHMENTS, true).withSetting(RecordFeature.SHARING, "accessApproverPermission", "sales.admin");
RecordFeaturesMetaData.ofOrWithNew(i.getTable("receipt")).withEnabled(RecordFeature.COMMENTS, true).withEnabled(RecordFeature.FAVORITES, false);
});
case 3 -> build(c -> c.withDefaultFeatures(), i ->
{
RecordFeaturesMetaData.ofOrWithNew(i.getApp("sales")).withEnabled(RecordFeature.TASKS, true).withPermissionMode(RecordFeature.TASKS, FeaturePermissionMode.GRANTED_ONLY);
RecordFeaturesMetaData.ofOrWithNew(i.getApp("ops")).withEnabled(RecordFeature.TASKS, false);
RecordFeaturesMetaData.ofOrWithNew(i.getApp("ops")).withSetting(RecordFeature.TAGS, "mode", "APPROVED_ONLY");
});
default -> throw new IllegalArgumentException();
}
}
/*******************************************************************************
** The proof: for every scenario the effective feature set is identical with
** and without the seeded rows, and rows really were created.
*******************************************************************************/
@Test
void seededRowsChangeNothing() throws Exception
{
for(int number = 0; number <= 3; number++)
{
scenario(number);
Map<String, String> without = effective();
assertThat(rows()).as("scenario " + number + " starts empty").isEmpty();
List<ProcessSummaryLine> summary = create();
assertThat(summary).as("scenario " + number).isNotEmpty();
List<QRecord> rows = rows();
assertThat(rows).as("scenario " + number + " rows").hasSizeGreaterThanOrEqualTo(RecordFeature.values().length);
assertThat(rows.stream().filter(row -> "INSTANCE".equals(row.getValueString("scopeType")))).hasSize(RecordFeature.values().length);
assertThat(effective()).as("scenario " + number + ": effective settings with the seeded rows").containsExactlyInAnyOrderEntriesOf(without);
tearDown();
}
}
/*******************************************************************************
** The default scenario in plain terms: favorites, recents and timeline are
** the only features on, TAGS carries the mode, everything else is off.
*******************************************************************************/
@Test
void defaultRowsMirrorTheConfiguredDefaults() throws Exception
{
scenario(0);
create();
for(QRecord row : rows())
{
assertThat(row.getValueString("scopeType")).isEqualTo("INSTANCE");
assertThat(row.getValueString("scopeName")).isEqualTo("instance");
RecordFeature feature = RecordFeature.valueOf(row.getValueString("feature"));
boolean on = List.of(RecordFeature.FAVORITES, RecordFeature.RECENTS, RecordFeature.TIMELINE).contains(feature);
assertThat(row.getValueBoolean("enabled")).as(feature.name()).isEqualTo(on);
assertThat(row.getValueString("permissionMode")).isEqualTo("EDIT_OR_GRANTED");
assertThat(row.getValueString("settings")).isEqualTo(feature == RecordFeature.TAGS ? "{\"mode\":\"FREE\"}" : null);
}
}
/*******************************************************************************
** Running it again creates nothing; an edit survives; and the rows are live:
** turning COMMENTS on in its row turns comments on for every host table.
*******************************************************************************/
@Test
void insertOnlyAndTheRowsAreLive() throws Exception
{
scenario(0);
create();
int count = rows().size();
assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("document")).isEnabled(RecordFeature.COMMENTS)).isFalse();
QRecord comments = rows().stream().filter(row -> "COMMENTS".equals(row.getValueString("feature"))).findFirst().orElseThrow();
new UpdateAction().execute(new UpdateInput(CollaborationTables.FEATURE_SETTING).withRecords(List.of(new QRecord().withValue("id", comments.getValue("id")).withValue("enabled", true))));
assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("document")).isEnabled(RecordFeature.COMMENTS)).as("the edited row drives the runtime").isTrue();
assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("invoice")).isEnabled(RecordFeature.COMMENTS)).isTrue();
List<ProcessSummaryLine> second = create();
assertThat(second).singleElement().satisfies(line -> assertThat(line.getMessage()).contains("already has rows"));
assertThat(rows()).hasSize(count);
assertThat(rows().stream().filter(row -> "COMMENTS".equals(row.getValueString("feature"))).findFirst().orElseThrow().getValueBoolean("enabled")).isTrue();
}
/*******************************************************************************
** A table that already has an administrator's rows gets nothing added.
*******************************************************************************/
@Test
void neverAddsToExistingRows() throws Exception
{
scenario(1);
CollaborationRecords.insert(CollaborationTables.FEATURE_SETTING, new QRecord().withValue("scopeType", "INSTANCE").withValue("scopeName", "instance")
.withValue("feature", "TASKS").withValue("enabled", true));
assertThat(FeatureSettingsDefaults.missingRows(instance, config)).isEmpty();
create();
assertThat(rows()).hasSize(1);
}
/*******************************************************************************
** Only collaboration administrators may run it.
*******************************************************************************/
@Test
void adminOnly() throws Exception
{
scenario(0);
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("viewer")).withPermissions(CollaborationPermissions.VIEW));
assertThatThrownBy(() -> new FeatureSettingsDefaultsProcess.AdminOnly().checkPermissionsThrowing(null, null)).isInstanceOf(QPermissionDeniedException.class);
assertThat(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getMinInputRecords()).isEqualTo(0);
assertThat(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getTableName()).isEqualTo(CollaborationTables.FEATURE_SETTING);
}
/*******************************************************************************
** The Setup card reports the configuration, then the rows, and what is on where.
*******************************************************************************/
@Test
void setupCardUsesTheRuntimeResolution() throws Exception
{
scenario(1);
List<CollaborationSetupStatus.Item> before = CollaborationSetupStatus.load(instance);
assertThat(before).hasSize(RecordFeature.values().length + 1);
assertThat(before.getFirst().state()).isEqualTo("Using code defaults");
assertThat(before.getFirst().nextStep()).contains("Create rows from configuration");
assertThat(item(before, "Comments").state()).isEqualTo("On for 2 of 3 tables");
assertThat(item(before, "Comments").where()).isEqualTo("Invoices, Receipts");
assertThat(item(before, "Favorites").state()).isEqualTo("Off");
assertThat(item(before, "Tasks").state()).isEqualTo("On for 1 of 3 tables");
assertThat(item(before, "Tasks").where()).isEqualTo("Documents");
assertThat(item(before, "Presence").state()).isEqualTo("Off");
assertThat(item(before, "Presence").nextStep()).contains("Feature Settings");
assertThat(item(before, "Tasks").source()).isEqualTo("Configuration");
create();
List<CollaborationSetupStatus.Item> after = CollaborationSetupStatus.load(instance);
assertThat(after.getFirst().state()).isEqualTo("Set up");
assertThat(item(after, "Tasks").source()).isEqualTo("Feature Settings");
List<String> beforeStates = new ArrayList<>(before.stream().skip(1).map(CollaborationSetupStatus.Item::state).toList());
assertThat(after.stream().skip(1).map(CollaborationSetupStatus.Item::state).toList()).as("the card reports the same thing before and after").isEqualTo(beforeStates);
}
/*******************************************************************************
** The load is one transaction: while it is open (and after it fails and is
** rolled back) the override overlay never sees any of its rows, so a failure
** on a later page cannot leave Instance rows without the rows that restate
** the code.
*******************************************************************************/
@Test
void laterPageFailureNeverReachesTheOverlay() throws Exception
{
scenario(1);
long generation = RuntimeMetaDataOverlay.getGeneration(instance);
FeatureSettingsDefaultsProcess.Load load = new FeatureSettingsDefaultsProcess.Load();
assertThat(load.openTransaction(new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput())).isPresent();
com.kingsrook.qqq.backend.core.actions.QBackendTransaction tx = new com.kingsrook.qqq.backend.core.actions.QBackendTransaction();
load.setTransaction(java.util.Optional.of(tx));
List<QRecord> all = FeatureSettingsDefaults.missingRows(instance, config);
var page1 = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput();
page1.setRecords(new ArrayList<>(all.subList(0, 3)));
load.runOnePage(page1, new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput());
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).as("no refresh before commit").isEqualTo(generation);
var page2 = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput();
page2.setRecords(new ArrayList<>(List.of(new QRecord().withValue("scopeType", "INSTANCE"))));
assertThatThrownBy(() -> load.runOnePage(page2, new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput()))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QException.class);
tx.rollback();
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).as("rolled back: the overlay never changed").isEqualTo(generation);
}
/*******************************************************************************
**
*******************************************************************************/
private static CollaborationSetupStatus.Item item(List<CollaborationSetupStatus.Item> items, String feature)
{
return (items.stream().filter(item -> feature.equals(item.feature())).findFirst().orElseThrow());
}
}
@@ -0,0 +1,109 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowers;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Exercise the installed provider through core's public event-subscriber facade.
*******************************************************************************/
class FollowersProviderTest extends PersonalServiceFixture
{
/*******************************************************************************
** Preferences, mutes and parent scope apply even for callers without access.
*******************************************************************************/
@Test
void resolvesEventSubscribersAsSystemWithoutFilteringRecipientAccess() throws Exception
{
seed("change", "document", "1", true, false, null, false);
seed("comment", "document", "1", false, true, null, false);
seed("both", "document", "1", true, true, Instant.now().minusSeconds(60), false);
seed("muted", "document", "1", true, true, Instant.now().plusSeconds(3600), false);
seed("neither", "document", "1", false, false, null, false);
seed("other-record", "document", "2", true, true, null, false);
seed("other-table", "other", "1", true, true, null, false);
seed("deleted", "document", "1", true, true, null, true);
RecordReference reference = new RecordReference("document", 1);
for(QSession session : List.of(new QSession(), new QSystemUserSession(instance)))
{
QContext.init(instance, session);
for(FollowEvent event : List.of(FollowEvent.ALERT, FollowEvent.CHANGE))
{
assertThat(RecordFollowers.get(instance, reference, event)).extracting(RecordFollower::getUserId)
.containsExactlyInAnyOrder("change", "both");
}
assertThat(RecordFollowers.get(instance, reference, FollowEvent.COMMENT)).extracting(RecordFollower::getUserId)
.containsExactlyInAnyOrder("comment", "both");
assertThat(RecordFollowers.get(instance, reference, FollowEvent.CHANGE)).filteredOn(f -> f.getUserId().equals("change"))
.singleElement().satisfies(f ->
{
assertThat(f.getNotifyOnChange()).isTrue();
assertThat(f.getNotifyOnComment()).isFalse();
assertThat(f.getReason()).isEqualTo("MANUAL");
});
assertThat(QContext.getQSession()).isSameAs(session);
}
}
/*******************************************************************************
** Existing subscriptions do not override the host's FOLLOW feature switch.
*******************************************************************************/
@Test
void disabledFollowReturnsEmpty() throws Exception
{
seed("follower", "document", "1", true, true, null, false);
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.FOLLOW, false);
RuntimeMetaDataOverlay.refresh(instance);
for(FollowEvent event : FollowEvent.values())
{
assertThat(RecordFollowers.get(instance, new RecordReference("document", 1), event)).isEmpty();
}
}
/*******************************************************************************
** Seed persisted preferences directly, including subscriptions lacking access.
*******************************************************************************/
private void seed(String user, String table, String id, Boolean change, Boolean comment, Instant muted, Boolean deleted) throws Exception
{
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.FOLLOW).withRecords(List.of(new QRecord()
.withValue("userId", user).withValue("tableName", table).withValue("recordId", id).withValue("reason", "MANUAL")
.withValue("notifyOnChange", change).withValue("notifyOnComment", comment).withValue("mutedUntil", muted)
.withValue("parentDeleted", deleted))), true);
}
}
@@ -0,0 +1,66 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
/*******************************************************************************
** Test view of the home KPI widget: statistic label to value, and group subheaders.
*******************************************************************************/
public final class HomeWidgetData
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private HomeWidgetData()
{
}
/*******************************************************************************
** Every statistic on the KPI widget, by label.
*******************************************************************************/
public static Map<String, Integer> stats(Instant now) throws QException
{
Map<String, Integer> result = new LinkedHashMap<>();
CollaborationHome.kpis(now).getStatisticsGroupData().forEach(group -> group.getStatisticList().forEach(stat -> result.put(stat.getLabel(), stat.getValue())));
return (result);
}
/*******************************************************************************
** The subheader of the named KPI group.
*******************************************************************************/
public static String subheader(MultiStatisticsData data, String header)
{
return (data.getStatisticsGroupData().stream().filter(group -> header.equals(group.getHeader())).findFirst().orElseThrow().getSubheader());
}
}
@@ -134,13 +134,19 @@ class PresenceTest extends PersonalServiceFixture
login("alice");
assertThat(service.snapshot("document", "1")).isEmpty();
service.heartbeat("table", "document", null, "VIEWING", null);
admin();
assertThat(CollaborationHome.presence().getRows()).extracting(row -> row.get("table")).containsExactly("Documents");
assertThat(CollaborationHome.presence().getRows().getFirst().get("viewers")).isEqualTo(1);
login("alice");
assertThat(CollaborationHome.presence().getRows()).isEmpty();
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY);
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.PRESENCE, false);
RuntimeMetaDataOverlay.refresh(instance);
assertThatThrownBy(() -> service.snapshot("document", null)).isInstanceOf(QNotFoundException.class);
assertThatThrownBy(() -> service.heartbeat("table", "document", null, "VIEWING", null)).isInstanceOf(QNotFoundException.class);
admin();
assertThat(CollaborationRecords.query(com.kingsrook.qbits.collaboration.model.CollaborationTables.USER, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty();
assertThat(CollaborationHome.render(Instant.now())).contains("Who's here", "Custom fields", "Index NONE");
assertThat(CollaborationHome.fields().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_FIELDS);
}
/*******************************************************************************
@@ -0,0 +1,159 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.comments.CommentsService;
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier;
import com.kingsrook.qbits.collaboration.notify.RecordingNotifier;
import com.kingsrook.qbits.collaboration.recents.RecentsService;
import com.kingsrook.qbits.collaboration.sharing.SharingService;
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Cached and delivered labels respect the session that will receive the data.
*******************************************************************************/
class SecuredLabelsTest extends PersonalServiceFixture
{
private static RecordingNotifier recorder;
/*******************************************************************************
** Non-FULL labels are never cached; lists always re-read as the current viewer.
*******************************************************************************/
@Test
void cachesOnlyFullAccessLabelsAndRefreshesForViewer() throws Exception
{
for(FieldSecurityLock.Behavior behavior : List.of(FieldSecurityLock.Behavior.MASK, FieldSecurityLock.Behavior.DENY, FieldSecurityLock.Behavior.REVEAL))
{
restrictLabel(behavior);
FavoritesService.star("RECORD", "document", 1);
RecentsService.recordView("RECORD", "document", 1);
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
{
assertThat(PersonalRecords.query(table, PersonalRecords.ownFilter())).singleElement()
.satisfies(row -> assertThat(row.getValueString("label")).isNull());
}
assertThat(FavoritesService.list()).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
assertThat(RecentsService.list(50)).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
FavoritesService.unstar("RECORD", "document", 1);
PersonalRecords.delete(CollaborationTables.RECENT, PersonalRecords.query(CollaborationTables.RECENT, PersonalRecords.ownFilter()).stream().map(row -> row.getValue("id")).toList());
}
QContext.getQSession().withSecurityKeyValue("sensitive", "full");
FavoritesService.star("RECORD", "document", 1);
RecentsService.recordView("RECORD", "document", 1);
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
{
assertThat(PersonalRecords.query(table, PersonalRecords.ownFilter())).singleElement()
.satisfies(row -> assertThat(row.getValueString("label")).isEqualTo("First document"));
}
login("alice");
assertThat(FavoritesService.list()).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
assertThat(RecentsService.list(50)).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
FavoritesService.star("TABLE", "document", null);
assertThat(PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.targetFilter("TABLE", "document", null)))
.singleElement().satisfies(row -> assertThat(row.getValueString("label")).isEqualTo("Documents"));
}
/*******************************************************************************
** The sender's FULL label cannot leak to recipients with MASKED or HIDDEN access.
*******************************************************************************/
@Test
void notificationTitlesResolveLabelsPerRecipient() throws Exception
{
for(FieldSecurityLock.Behavior behavior : List.of(FieldSecurityLock.Behavior.MASK, FieldSecurityLock.Behavior.DENY))
{
restrictLabel(behavior);
QContext.getQSession().withSecurityKeyValue("sensitive", "full");
recorder = new RecordingNotifier();
config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionProvider(new QCodeReference(TestSessions.class));
new SharingService().share("document", 1, List.of("restricted", "full"), "Shared user content");
new CommentsService().post("document", 1, "@[Restricted](user:restricted) @[Full](user:full)", null, null);
assertThat(recorder.notifications()).hasSize(4);
assertThat(recorder.notifications()).filteredOn(n -> n.recipientUserId().equals("restricted")).hasSize(2)
.allSatisfy(n -> assertThat(n.title()).contains("Documents #1").doesNotContain("First document"));
assertThat(recorder.notifications()).filteredOn(n -> n.recipientUserId().equals("full")).hasSize(2)
.allSatisfy(n -> assertThat(n.title()).contains("First document"));
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo("alice");
}
}
/*******************************************************************************
** Field-lock override grants FULL to selected sessions only.
*******************************************************************************/
private void restrictLabel(FieldSecurityLock.Behavior behavior)
{
if(!instance.getSecurityKeyTypes().containsKey("sensitive"))
{
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
}
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(behavior).withOverrideValues(List.of("full")));
}
/*******************************************************************************
** Recipients share record access but have different label access.
*******************************************************************************/
public static class TestSessions implements UserSessionProviderInterface
{
/*******************************************************************************
** Build independent recipient sessions for real core field checks.
*******************************************************************************/
@Override
public Optional<QSession> sessionForUser(String userId)
{
QSession session = new QSession().withUser(new QUser().withIdReference(userId)).withPermissions("document.read")
.withSecurityKeyValue("tenant", "a");
if(userId.equals("full"))
{
session.withSecurityKeyValue("sensitive", "full");
}
return (Optional.of(session));
}
}
/*******************************************************************************
** Capture notifications before any external transport.
*******************************************************************************/
public static class TestNotifier implements CollaborationNotifier
{
/*******************************************************************************
** Preserve the payload for assertions.
*******************************************************************************/
@Override
public void notify(CollaborationNotification notification)
{
recorder.notify(notification);
}
}
}
@@ -31,7 +31,6 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
import com.kingsrook.qbits.collaboration.comments.CommentsService;
import com.kingsrook.qbits.collaboration.follow.FollowService;
@@ -510,8 +509,7 @@ public class SharingWorkTest extends PersonalServiceFixture
sharing.share("document", 1, List.of("bob"), null);
access.request("document", 999, null, null);
admin();
String html = CollaborationHome.render(Instant.now().plusSeconds(5));
assertThat(html).contains("Open access requests</h3><p>1", "Open tasks</h3><p>1", "Overdue tasks</h3><p>1", "Shares this week</h3><p>1");
assertThat(HomeWidgetData.stats(Instant.now().plusSeconds(5))).containsEntry("Open access requests", 1).containsEntry("Open tasks", 1).containsEntry("Overdue tasks", 1);
assertThat(SendTaskRemindersProcess.metadata(config).getIsHidden()).isTrue();
assertThat(SendTaskRemindersProcess.metadata(config).getSchedule()).isNull();
assertThat(SendTaskRemindersProcess.metadata(config.withSchedulerName("clock")).getSchedule().getRepeatSeconds()).isEqualTo(60);
@@ -22,7 +22,6 @@
package com.kingsrook.qbits.collaboration;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -311,9 +310,11 @@ public class TagsServiceTest extends PersonalServiceFixture
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b"));
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_TAG)).getRecords()).isEmpty();
login("manager", "collaboration.admin", TagsService.MANAGE);
String html = CollaborationHome.render(Instant.now());
assertThat(html).contains("Vocabulary size: 2", "Pending suggestions: 1", "&lt;Urgent&gt;: 1");
assertThat(html).doesNotContain("<Urgent>");
var home = CollaborationHome.tags();
assertThat(home.getRows()).hasSize(2);
assertThat(home.getRows().getFirst()).containsEntry("label", "<Urgent>").containsEntry("table", "Documents").containsEntry("records", 1);
assertThat(home.getRows().getLast()).containsEntry("records", 0);
assertThat(home.getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_TAGS);
}
/*******************************************************************************
@@ -445,6 +446,36 @@ public class TagsServiceTest extends PersonalServiceFixture
{
return ((List<ProcessSummaryLine>) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY));
}
/*******************************************************************************
** JSON clients send ids as Integer, Long, BigDecimal or String; all select by id.
*******************************************************************************/
@Test
void translatorAcceptsEveryIdRepresentation() throws Exception
{
TagEntry a = tags.create("document", "A", null);
TagEntry b = tags.create("document", "B", null);
TagEntry numeric = tags.create("document", "77", null);
tags.apply("document", 1, List.of(a.id(), b.id()), List.of());
tags.apply("document", 2, List.of(a.id()), List.of());
for(java.io.Serializable idA : List.<java.io.Serializable>of(a.id(), Long.valueOf(a.id()), new java.math.BigDecimal(a.id()), String.valueOf(a.id())))
{
assertThat(recordIds("document", "IN", List.of(idA))).containsExactly("1", "2");
assertThat(recordIds("document", "EQUALS", List.of(idA))).containsExactly("1", "2");
assertThat(recordIds("document", "NOT_IN", List.of(idA))).isEmpty();
}
for(java.io.Serializable idB : List.<java.io.Serializable>of(b.id(), Long.valueOf(b.id()), new java.math.BigDecimal(b.id()), String.valueOf(b.id())))
{
assertThat(recordIds("document", "IN", List.of(idB))).containsExactly("1");
assertThat(recordIds("document", "NOT_IN", List.of(idB))).containsExactly("2");
assertThat(recordIds("document", "CONTAINS_ALL", List.of(String.valueOf(a.id()), idB))).containsExactly("1");
}
assertThat(recordIds("document", "CONTAINS_ALL", List.of(new java.math.BigDecimal(a.id()), Long.valueOf(b.id())))).containsExactly("1");
assertThat(recordIds("document", "IN", List.of("9999"))).isEmpty();
tags.apply("document", 2, List.of(numeric.id()), List.of());
assertThat(recordIds("document", "IN", List.of(String.valueOf(numeric.id())))).containsExactly("2");
assertThat(recordIds("document", "IN", List.of("77"))).containsExactly("2");
}
/*******************************************************************************
** Execute expanded criteria through the real parent query action.
*******************************************************************************/
@@ -26,11 +26,16 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.timeline.TimelineDetail;
import com.kingsrook.qbits.collaboration.timeline.TimelineItem;
import com.kingsrook.qbits.collaboration.timeline.TimelinePage;
import com.kingsrook.qbits.collaboration.timeline.TimelineService;
import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldMaskStyle;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldValueMasker;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.metadata.personalization.TableMetaDataPersonalizerInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
@@ -116,6 +121,48 @@ public class TimelineServiceTest extends PersonalServiceFixture
assertThat(timeline.timeline("document", 2, null, 50, null).items()).isEmpty();
}
/*******************************************************************************
** Core wording becomes who-did-what; detail rows and unknown messages are untouched.
*******************************************************************************/
@Test
void auditSummariesSayWhoDidWhat() throws Exception
{
seedAudit(4, "Record was Edited", 100);
seedAudit(5, "Record was Inserted", 101);
seedAudit(6, "Record was Edited", 102);
seedAudit(7, "Record was Deleted", 103);
seed("auditDetail", new QRecord().withValue("id", 10L).withValue("auditId", 4L).withValue("fieldName", "title").withValue("oldValue", "Old").withValue("newValue", "New"));
seed("auditDetail", new QRecord().withValue("id", 11L).withValue("auditId", 4L).withValue("fieldName", "tenant").withValue("oldValue", "a").withValue("newValue", "b"));
seed("auditDetail", new QRecord().withValue("id", 12L).withValue("auditId", 4L).withValue("fieldName", "title").withValue("oldValue", "New").withValue("newValue", "Newer"));
seed("auditDetail", new QRecord().withValue("id", 13L).withValue("auditId", 5L).withValue("fieldName", "title").withValue("newValue", "Created title"));
var items = timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items();
java.util.function.Function<Long, TimelineItem> byId = id -> items.stream().filter(item -> item.id().equals(id)).findFirst().orElseThrow();
assertThat(byId.apply(4L).summary()).isEqualTo("Edited by Audit Actor: Title, Tenant");
assertThat(byId.apply(4L).details()).hasSize(3);
assertThat(byId.apply(5L).summary()).isEqualTo("Created by Audit Actor");
assertThat(byId.apply(5L).details()).hasSize(1);
assertThat(byId.apply(6L).summary()).isEqualTo("Edited by Audit Actor");
assertThat(byId.apply(7L).summary()).isEqualTo("Deleted by Audit Actor");
assertThat(byId.apply(1L).summary()).as("unrecognized messages are kept").isEqualTo("Record changed");
assertThat(items).extracting(TimelineItem::summary).noneMatch(summary -> summary.startsWith("Record was"));
assertThat(TimelineService.friendlySummary("Record was Inserted", null, List.of(), null)).isEqualTo("Created");
assertThat(TimelineService.friendlySummary("Record was Inserted via import", " ", List.of(), null)).isEqualTo("Created");
assertThat(TimelineService.friendlySummary(null, "A", List.of(), null)).isNull();
List<TimelineDetail> many = java.util.stream.IntStream.rangeClosed(1, 6).mapToObj(n -> new TimelineDetail("f" + n, "a", "b", null)).toList();
assertThat(TimelineService.friendlySummary("Record was Edited", "A", many, null)).isEqualTo("Edited by A: f1, f2, f3, f4 and 2 more");
assertThat(TimelineService.friendlySummary("Record was Edited", "A", List.of(new TimelineDetail(null, null, null, "note"), new TimelineDetail(" ", null, null, null)), null)).isEqualTo("Edited by A");
}
/*******************************************************************************
** One audit header for the first document, as a different time.
*******************************************************************************/
private void seedAudit(long id, String message, long secondsAfter) throws Exception
{
seed("audit", new QRecord().withValue("id", id).withValue("auditTableId", 1).withValue("auditUserId", 1)
.withValue("recordId", "1").withValue("timestamp", at.plusSeconds(secondsAfter)).withValue("message", message));
}
/*******************************************************************************
** Audit read permission and hidden fields apply.
*******************************************************************************/
@@ -139,6 +186,40 @@ public class TimelineServiceTest extends PersonalServiceFixture
assertThat(timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items()).isEmpty();
}
/*******************************************************************************
** RC.7 QueryAction masks audit values and removes HIDDEN detail rows as the viewer.
*******************************************************************************/
@Test
void fieldTiersProtectTimelineValuesAndMessages() throws Exception
{
String canary = "TIMELINE-SECRET-CANARY";
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK).withMaskStyle(FieldMaskStyle.FULL));
instance.getTable("document").getField("tenant").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.DENY));
seedAudit(4, canary, 100);
for(String field : List.of("title", "tenant"))
{
seed("auditDetail", new QRecord().withValue("id", field.equals("title") ? 10L : 11L).withValue("auditId", 4L)
.withValue("fieldName", field).withValue("oldValue", canary).withValue("newValue", canary).withValue("message", canary));
}
TimelinePage page = timeline.timeline("document", 1, null, 50, Set.of("AUDIT"));
assertThat(page.items()).filteredOn(item -> item.id().equals(4L)).singleElement().satisfies(item ->
{
assertThat(item.summary()).isNull();
assertThat(item.details()).singleElement().satisfies(detail ->
{
assertThat(detail.fieldName()).isEqualTo("title");
assertThat(detail.oldValue()).isEqualTo(FieldValueMasker.MASK);
assertThat(detail.newValue()).isEqualTo(FieldValueMasker.MASK);
assertThat(detail.message()).isNull();
});
});
assertThat(page.items().stream().flatMap(item -> item.details().stream())).noneMatch(detail -> "tenant".equals(detail.fieldName()));
assertThat(page.toString()).doesNotContain(canary, "Old", "New", "sensitive old", "sensitive new");
}
/*******************************************************************************
** Real personalizer boundary, like core's audit screen.
*******************************************************************************/
@@ -34,6 +34,7 @@ import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationSchema;
import com.kingsrook.qbits.collaboration.HomeWidgetData;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
@@ -101,6 +102,20 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
/*******************************************************************************
** Upload, metadata, newest-first list, signed download and deletion round trip.
*******************************************************************************/
/*******************************************************************************
** The action steps can be constructed without a QContext, as QQQ does during
** instance validation (issue #20); the service is created on first use.
*******************************************************************************/
@Test
void stepsConstructWithoutContext()
{
QContext.clear();
assertThat(new DeleteAttachmentsProcess.Review()).isNotNull();
assertThat(new DeleteAttachmentsProcess.Delete()).isNotNull();
}
@Test
void roundTrip() throws Exception
{
@@ -430,10 +445,17 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
void homeAndSchema() throws Exception
{
admin();
assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 0", "No attachments yet.", "Total size (bytes): 0");
assertThat(HomeWidgetData.stats(Instant.now())).containsEntry("Attachments", 0);
assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Attachments")).isEqualTo("No attachments yet");
upload("a.txt", "12345");
upload("b.txt", "123");
assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 2", "Total size (bytes): 8", "Approximate totals").doesNotContain("No attachments yet.");
assertThat(HomeWidgetData.stats(Instant.now())).containsEntry("Attachments", 2);
assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Attachments")).isEqualTo("8 B in total");
assertThat(CollaborationHome.humanSize(0)).isEqualTo("0 B");
assertThat(CollaborationHome.humanSize(1023)).isEqualTo("1023 B");
assertThat(CollaborationHome.humanSize(4813)).isEqualTo("4.7 KB");
assertThat(CollaborationHome.humanSize(5L * 1024 * 1024)).isEqualTo("5.0 MB");
assertThat(CollaborationHome.humanSize(3L * 1024 * 1024 * 1024)).isEqualTo("3.0 GB");
String ddl = CollaborationSchema.postgresScript(config);
assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS collaboration_attachment", "size_bytes BIGINT", "parent_deleted BOOLEAN DEFAULT FALSE NOT NULL",
"ON collaboration_attachment (table_name, record_id)").doesNotContain("preview_url", "download_url");
@@ -0,0 +1,733 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.devserver;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationQBitProducer;
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
import com.kingsrook.qbits.collaboration.attachments.AttachmentsService;
import com.kingsrook.qbits.collaboration.comments.CommentsService;
import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions;
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
import com.kingsrook.qbits.collaboration.follow.FollowService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.RecordingNotifier;
import com.kingsrook.qbits.collaboration.people.PeopleService;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qbits.collaboration.recents.RecentsService;
import com.kingsrook.qbits.collaboration.sharing.AccessRequestService;
import com.kingsrook.qbits.collaboration.sharing.PermissionHolderDirectory;
import com.kingsrook.qbits.collaboration.sharing.SharingService;
import com.kingsrook.qbits.collaboration.tags.TagsService;
import com.kingsrook.qbits.collaboration.tasks.TasksService;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
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.audits.AuditsMetaDataProvider;
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.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordOwnership;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
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.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import io.javalin.config.JavalinConfig;
/*******************************************************************************
** Disposable Sales CRM for viewing collaboration in the Next dashboard.
** Build the Next export with QQQ_NEXT_OUTPUT=export pnpm build, then place out/
** under EXPORT/next-dashboard/ (EXPORT, not next-dashboard, is on the classpath).
** After mvnw.cmd test-compile dependency:build-classpath -Dmdep.includeScope=test
** -Dmdep.outputFile=target/demo-classpath.txt, run:
** java -Dqqq.javalin.frontend=next -Dcollab.dev.user=alice -Dcollab.dev.port=8098
** -cp "target/test-classes;target/classes;DEPS;EXPORT" com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer
** DEPS is the content of target/demo-classpath.txt. See README for PowerShell.
*******************************************************************************/
public class CollaborationDevServer extends AbstractQQQApplication
{
private final QInstance instance;
static final List<String> USERS = List.of("alice", "bob", "admin");
static final String USER_COOKIE = "collab_dev_user";
private static final ThreadLocal<String> REQUEST_USER = new ThreadLocal<>();
/*******************************************************************************
** Keep the already seeded instance when Javalin initializes the application.
*******************************************************************************/
public CollaborationDevServer(QInstance instance)
{
this.instance = instance;
}
/*******************************************************************************
** No HTTP server is needed to build or test the demo.
*******************************************************************************/
@Override
public QInstance defineQInstance()
{
return (instance);
}
/*******************************************************************************
** Select a demo identity for this JVM; restarting resets all demo data.
*******************************************************************************/
public static void main(String[] args) throws Exception
{
String user = System.getProperty("collab.dev.user", "alice");
session(user);
int port = Integer.parseInt(System.getProperty("collab.dev.port", "8098"));
QInstance instance = buildInstance(port);
seed(instance);
QApplicationJavalinServer server = server(instance, port, true);
server.start();
Runtime.getRuntime().addShutdownHook(new Thread(() ->
{
server.stop();
PresenceRegistry.close(instance);
RealtimeBus.close(instance);
}));
System.out.println("COLLABORATION DEV SERVER READY http://localhost:" + port + "/?createMockSession=true");
System.out.println("Person: http://localhost:" + port + "/app/person/1 Order: http://localhost:" + port + "/app/sales_order/1");
System.out.println("Users (-Dcollab.dev.user): alice (Sales), bob (people read/comment), admin (all + collaboration.admin). Current: " + user);
Thread.currentThread().join();
}
/*******************************************************************************
** Share the actual startup configuration with the HTTP smoke test.
*******************************************************************************/
static QApplicationJavalinServer server(QInstance instance, int port, boolean frontend)
{
return (new QApplicationJavalinServer(new CollaborationDevServer(instance)).withPort(port)
.withServeFrontendNext(frontend).withServeFrontendMaterialDashboard(false)
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
.withJavalinConfigCustomizer(config ->
{
config.jetty.host = "127.0.0.1";
userSelection(config);
MemoryStorage.routes(config);
}));
}
/*******************************************************************************
** Pick the demo user per browser, not per JVM. A request carrying ?user=bob (for
** example the page load of /?createMockSession=true&user=bob) is remembered in a
** cookie, and every request exposes its choice to the session customizer, which
** QQQ calls without the request. Without either, the collab.dev.user default holds.
*******************************************************************************/
static void userSelection(JavalinConfig config)
{
config.routes.before(context ->
{
String requested = context.queryParam("user");
if(requested != null && USERS.contains(requested))
{
context.cookie(USER_COOKIE, requested);
REQUEST_USER.set(requested);
return;
}
String remembered = context.cookie(USER_COOKIE);
REQUEST_USER.set(remembered != null && USERS.contains(remembered) ? remembered : null);
});
config.routes.after(context -> REQUEST_USER.remove());
}
/*******************************************************************************
** The user for the request being handled on this thread, else the JVM default.
*******************************************************************************/
static String selectedUser()
{
String requested = REQUEST_USER.get();
return (requested != null ? requested : System.getProperty("collab.dev.user", "alice"));
}
/*******************************************************************************
** Normal host metadata, plus the same collaboration installation as a real app.
*******************************************************************************/
static QInstance buildInstance() throws Exception
{
return (buildInstance(Integer.parseInt(System.getProperty("collab.dev.port", "8098"))));
}
/*******************************************************************************
** The port is part of the configuration: shared links are absolute URLs to this server.
*******************************************************************************/
static QInstance buildInstance(int port) throws Exception
{
QInstance instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData()
.withName("mock").withType(QAuthenticationType.MOCK).withCustomizer(new QCodeReference(DemoSessions.class)));
instance.setEnforcePermissionsInCoreActions(true);
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
instance.addPossibleValueSource(new QPossibleValueSource().withName("sales_user").withType(QPossibleValueSourceType.ENUM)
.withEnumValues(List.of(new QPossibleValue<>("alice", "Alice Morgan"), new QPossibleValue<>("bob", "Bob Chen"), new QPossibleValue<>("admin", "Demo Administrator"))));
instance.addPossibleValueSource(new QPossibleValueSource().withName("sales_order_status").withType(QPossibleValueSourceType.ENUM)
.withEnumValues(List.of(new QPossibleValue<>("Draft"), new QPossibleValue<>("Confirmed"), new QPossibleValue<>("Processing"), new QPossibleValue<>("Shipped"), new QPossibleValue<>("Delivered"))));
instance.addTable(baseTable("person", "People", "person")
.withRecordLabelFormatAndFields("%s %s", "firstName", "lastName")
.withField(new QFieldMetaData("firstName", QFieldType.STRING).withIsRequired(true))
.withField(new QFieldMetaData("lastName", QFieldType.STRING).withIsRequired(true))
.withField(new QFieldMetaData("email", QFieldType.STRING))
.withField(new QFieldMetaData("company", QFieldType.STRING))
.withField(new QFieldMetaData("title", QFieldType.STRING))
.withField(new QFieldMetaData("region", QFieldType.STRING))
.withField(new QFieldMetaData("ownerUserId", QFieldType.STRING).withLabel("Account owner").withPossibleValueSourceName("sales_user"))
.withField(new QFieldMetaData("assigneeUserId", QFieldType.STRING).withLabel("Account partner").withPossibleValueSourceName("sales_user"))
.withSection(new QFieldSection("contact", new QIcon("person"), Tier.T1, List.of("id", "firstName", "lastName", "email")).withLabel("Contact"))
.withSection(new QFieldSection("account", new QIcon("business"), Tier.T2, List.of("company", "title", "region", "ownerUserId", "assigneeUserId")).withLabel("Account")));
instance.addPossibleValueSource(QPossibleValueSource.newForTable("person"));
instance.addTable(baseTable("sales_order", "Sales orders", "receipt_long")
.withRecordLabelFormatAndFields("%s", "orderNo")
.withField(new QFieldMetaData("orderNo", QFieldType.STRING).withLabel("Order no.").withIsRequired(true))
.withField(new QFieldMetaData("customer", QFieldType.INTEGER).withPossibleValueSourceName("person").withIsRequired(true))
.withField(new QFieldMetaData("status", QFieldType.STRING).withPossibleValueSourceName("sales_order_status"))
.withField(new QFieldMetaData("total", QFieldType.DECIMAL))
.withField(new QFieldMetaData("orderDate", QFieldType.DATE))
.withSection(new QFieldSection("order", new QIcon("receipt_long"), Tier.T1, List.of("id", "orderNo", "customer", "status")).withLabel("Order"))
.withSection(new QFieldSection("commercial", new QIcon("payments"), Tier.T2, List.of("total", "orderDate")).withLabel("Commercial details")));
instance.addApp(new QAppMetaData().withName("sales").withLabel("Sales").withIcon(new QIcon("storefront"))
.withSection(new QAppSection().withName("customers").withLabel("Customers & orders").withTables(List.of("person", "sales_order"))));
instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin").withIcon(new QIcon("settings"))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("admin")));
new AuditsMetaDataProvider().withRecordIdType(QFieldType.STRING).withDefineRecordScreens(true)
.defineAll(instance, "memory", table -> table.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS)));
new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory")
.withAdminApp(true).withParentAppName("admin").withUiApi(true).withFrontendBaseUrl("http://localhost:" + port)
.withTableFeatures("person", RecordFeature.values()).withTableFeatures("sales_order", RecordFeature.values())
.withAttachmentStorage(new QCodeReference(MemoryStorage.class))
.withUserSessionProvider(new QCodeReference(DemoSessions.class))
.withPermissionHolderDirectory(new QCodeReference(DemoSessions.class))
.withNotifier(new QCodeReference(RecordingNotifier.class))
.withAssignActionTables(List.of("sales_order"))
.withFollowActionTables(List.of("person", "sales_order")).withFollowChangeTables(List.of("person", "sales_order"))
.withTagActionTables(List.of("person", "sales_order"))).produce(instance);
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("person")).withSetting(RecordFeature.TAGS, "mode", "FREE")
.withOwnership(new RecordOwnership().withOwnerFieldName("ownerUserId").withAssigneeFieldName("assigneeUserId"));
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("sales_order")).withSetting(RecordFeature.TAGS, "mode", "APPROVED_ONLY");
QContext.init(instance, session("admin"));
new QInstanceEnricher(instance).enrich();
new QInstanceValidator().validate(instance);
RuntimeMetaDataOverlay.refresh(instance);
return (instance);
}
/*******************************************************************************
** All Sales writes are permission checked and produce field-level audits.
*******************************************************************************/
private static QTableMetaData baseTable(String name, String label, String icon)
{
return (new QTableMetaData().withName(name).withLabel(label).withIcon(new QIcon(icon)).withBackendName("memory")
.withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)));
}
/*******************************************************************************
** Stable grants shared by MOCK browser sessions, mentions and share checks.
*******************************************************************************/
static QSession session(String user)
{
if(!USERS.contains(user))
{
throw (new IllegalArgumentException("collab.dev.user must be alice, bob or admin: " + user));
}
String name = switch(user)
{
case "alice" -> "Alice Morgan";
case "bob" -> "Bob Chen";
default -> "Demo Administrator";
};
QSession session = new QSession().withUser(new QUser().withIdReference(user).withFullName(name))
.withPermissions("person.read", "person.comment")
.withValueForFrontend("user", new HashMap<>(Map.of("name", name, "email", user + "@example.com")));
if(!user.equals("bob"))
{
session.withPermissions("audit.read", "auditDetail.read", "auditTable.read", "auditUser.read");
for(String table : List.of("person", "sales_order"))
{
for(String permission : List.of("read", "write", "insert", "edit", "delete", "comment", "attach", "tag", "share", "task", "assign"))
{
session.withPermissions(table + "." + permission);
}
}
}
if(user.equals("admin"))
{
session.withPermissions("admin.hasAccess", "collaboration.admin", "collaboration.view", "collaboration.tags.manage", "collaboration.fields.manage");
}
return (session);
}
/*******************************************************************************
** MOCK normally creates User:number / John Smith; replace both explicitly.
*******************************************************************************/
public static class DemoSessions implements QAuthenticationModuleCustomizerInterface, UserSessionProviderInterface, PermissionHolderDirectory
{
/*******************************************************************************
** Preserve the browser session UUID while applying the selected user's grants.
*******************************************************************************/
@Override
public void customizeSession(QInstance instance, QSession target, Map<String, Object> context)
{
QSession selected = session(selectedUser());
target.setUser(selected.getUser());
target.setPermissions(selected.getPermissions());
target.setValuesForFrontend(selected.getValuesForFrontend());
}
/*******************************************************************************
** Return fresh sessions, so access checks cannot mutate another caller.
*******************************************************************************/
@Override
public Optional<QSession> sessionForUser(String userId)
{
return (USERS.contains(userId) ? Optional.of(session(userId)) : Optional.empty());
}
/*******************************************************************************
** Access requests can find their configured approver without external IAM.
*******************************************************************************/
@Override
public Set<String> userIdsWithPermission(String permission)
{
return (USERS.stream().filter(user -> session(user).getPermissions().contains(permission)).collect(java.util.stream.Collectors.toSet()));
}
}
/*******************************************************************************
** Seed through normal actions/services, including lifecycle, access and audits.
*******************************************************************************/
static void seed(QInstance instance) throws Exception
{
for(String user : USERS)
{
QContext.init(instance, session(user));
new PeopleService().touch();
}
QContext.setQSession(session("admin"));
publishField(new QRecord().withValue("tableName", "person").withValue("name", "preferred_contact").withValue("label", "Preferred contact")
.withValue("type", "SELECT").withValue("options", "[{\"id\":\"email\",\"label\":\"Email\"},{\"id\":\"phone\",\"label\":\"Phone\"},{\"id\":\"video\",\"label\":\"Video call\"}]"));
publishField(new QRecord().withValue("tableName", "sales_order").withValue("name", "purchase_order_no").withValue("label", "Purchase order no.")
.withValue("type", "STRING").withValue("searchable", true));
seedSales();
seedCollaboration();
QContext.setQSession(session(System.getProperty("collab.dev.user", "alice")));
}
/*******************************************************************************
** A published definition contributes actual memory JsonPathStorage fields.
*******************************************************************************/
private static void publishField(QRecord definition) throws Exception
{
QRecord saved = insert(CollaborationTables.FIELD_DEFINITION, List.of(definition)).getFirst();
FieldDefinitions.apply(saved.getValue("id"), "Publish");
}
/*******************************************************************************
** Fictional but realistic accounts; stable IDs and random seed aid screenshots.
*******************************************************************************/
private static void seedSales() throws Exception
{
String[] names = { "Maya Patel", "James Rivera", "Olivia Brooks", "Noah Kim", "Emma Wilson", "Liam Foster", "Ava Mitchell", "Ethan Reed", "Sofia Garcia", "Lucas Bennett",
"Isabella Turner", "Mason Wright", "Amelia Scott", "Logan Hayes", "Charlotte Evans", "Oliver Chen", "Harper Davis", "Elijah Morgan", "Evelyn Price", "Henry Lewis",
"Abigail Ross", "Jack Sullivan", "Grace Nguyen", "Daniel Park", "Zoe Campbell" };
String[] companies = { "Northstar Outfitters", "Prairie Health Supply", "Harbor Coffee Roasters", "Summit Industrial", "Cedar Grove Foods", "Bluebird Logistics", "Riverbend Packaging", "Atlas Workshop", "Orchard Market", "Clearwater Labs" };
String[] titles = { "Purchasing Director", "Operations Manager", "Founder", "Supply Chain Lead", "Finance Director" };
String[] regions = { "Midwest", "Northeast", "West", "Southeast", "Southwest" };
List<QRecord> people = new ArrayList<>();
for(int i = 0; i < names.length; i++)
{
String[] name = names[i].split(" ");
people.add(new QRecord().withValue("id", i + 1).withValue("firstName", name[0]).withValue("lastName", name[1])
.withValue("email", (name[0] + "." + name[1]).toLowerCase(java.util.Locale.ROOT) + "@example.com")
.withValue("company", companies[i % companies.length]).withValue("title", titles[i % titles.length]).withValue("region", regions[i % regions.length])
.withValue("ownerUserId", "alice").withValue("assigneeUserId", i % 3 == 0 ? "bob" : "alice")
.withValue("cf_preferred_contact", i % 3 == 0 ? "phone" : "email"));
}
insert("person", people);
Random random = new Random(8098);
List<QRecord> orders = new ArrayList<>();
String[] statuses = { "Draft", "Confirmed", "Processing", "Shipped", "Delivered" };
for(int i = 0; i < 40; i++)
{
orders.add(new QRecord().withValue("id", i + 1).withValue("orderNo", "SO-2026-" + (1001 + i)).withValue("customer", i % 25 + 1)
.withValue("status", statuses[i % statuses.length]).withValue("total", BigDecimal.valueOf(75000 + random.nextInt(2400000), 2))
.withValue("orderDate", LocalDate.of(2026, 9, 1).plusDays(i)).withValue("cf_purchase_order_no", "PO-" + (8400 + i)));
}
insert("sales_order", orders);
}
/*******************************************************************************
** Person 1 and order 1 contain the richest panels for the first demo visit.
*******************************************************************************/
private static void seedCollaboration() throws Exception
{
TagsService tags = new TagsService();
for(String table : List.of("person", "sales_order"))
{
for(String label : List.of("vip", "renewal", "at-risk", "new-business"))
{
var tag = tags.create(table, label, label.equals("at-risk") ? "red" : "blue");
tags.apply(table, 1, List.of(tag.id()), List.of());
tags.apply(table, label.equals("vip") ? 2 : 3, List.of(tag.id()), List.of());
}
}
QContext.setQSession(session("alice"));
CommentsService comments = new CommentsService();
comments.post("person", 1, "Renewal review is Thursday. @[Bob Chen](user:bob), please confirm Maya's preferred delivery window.", null, null);
QContext.setQSession(session("bob"));
comments.post("person", 1, "Confirmed: Tuesday mornings work best. @[Alice Morgan](user:alice), the warehouse contact is ready.", null, null);
QContext.setQSession(session("alice"));
var resolved = comments.post("person", 1, "Please check the purchasing title before sending the proposal.", null, "title");
comments.post("person", 1, "Maya confirmed her new title on our call.", resolved.id(), null);
comments.resolve(resolved.id());
comments.post("sales_order", 1, "Annual replenishment order. @[Demo Administrator](user:admin), please review the purchase order reference.", null, null);
comments.post("person", 2, "Expansion opportunity: add the two new Midwest locations to the renewal proposal.", null, null);
AttachmentsService attachments = new AttachmentsService();
attachments.upload("person", 1, "northstar-account-plan.png", "image/png", new ByteArrayInputStream(picture()), "Account plan for the renewal review");
attachments.upload("sales_order", 1, "order-confirmation.pdf", "application/pdf", new ByteArrayInputStream(pdf()), "Draft confirmation for SO-2026-1001");
attachments.upload("sales_order", 2, "delivery-plan.png", "image/png", new ByteArrayInputStream(picture()), "Proposed delivery schedule");
Instant now = Instant.now();
TasksService tasks = new TasksService();
tasks.create("person", 1, "Send the renewal proposal", "Include the expanded delivery schedule.", "alice", now.minusSeconds(86400), null);
tasks.create("person", 1, "Confirm warehouse contact", "Check the receiving team's direct number.", "bob", now.plusSeconds(172800), null);
tasks.create("sales_order", 1, "Approve purchase order reference", "Match the reference on the customer's PDF.", "alice", now.plusSeconds(86400), null);
var completed = tasks.create("person", 2, "Review regional pricing", "Use the current Midwest price list.", "alice", now.plusSeconds(86400), null);
tasks.complete(completed.getValueInteger("id"));
new AssignmentService().assign("sales_order", 1, "alice", "admin");
new AssignmentService().assign("sales_order", 2, "alice", "alice");
new SharingService().share("person", 1, List.of("bob"), "Please review the account before Thursday.");
new SharingService().share("sales_order", 1, List.of("bob"), "Please check the delivery plan.");
QContext.setQSession(session("bob"));
new AccessRequestService().request("sales_order", "1", "I need order access to coordinate the Northstar delivery.", null);
QContext.setQSession(session("alice"));
for(String table : List.of("person", "sales_order"))
{
for(int id = 1; id <= 3; id++)
{
new FollowService().follow(table, id, true, true);
FavoritesService.star("RECORD", table, id);
RecentsService.recordView("RECORD", table, id);
}
}
update("person", new QRecord().withValue("id", 1).withValue("title", "VP of Purchasing").withValue("cf_preferred_contact", "video"));
update("sales_order", new QRecord().withValue("id", 1).withValue("status", "Confirmed").withValue("total", new BigDecimal("18750.00")));
update("sales_order", new QRecord().withValue("id", 2).withValue("status", "Processing"));
}
/*******************************************************************************
** Fail startup on row-level action errors instead of showing a partial demo.
*******************************************************************************/
private static List<QRecord> insert(String table, List<QRecord> records) throws Exception
{
return (checked(new InsertAction().execute(new InsertInput(table).withRecords(records)).getRecords()));
}
/*******************************************************************************
** Ordinary updates generate real audit details and follow-change events.
*******************************************************************************/
private static void update(String table, QRecord record) throws Exception
{
checked(new UpdateAction().execute(new UpdateInput(table).withRecords(List.of(record))).getRecords());
}
/*******************************************************************************
** Both insert and update may return errors without throwing an exception.
*******************************************************************************/
private static List<QRecord> checked(List<QRecord> records)
{
for(QRecord record : records)
{
if(record.getErrors() != null && !record.getErrors().isEmpty())
{
throw (new IllegalStateException("Demo seed failed: " + record.getErrors()));
}
}
return (records);
}
/*******************************************************************************
** Generate a real image so previews need no downloaded assets.
*******************************************************************************/
private static byte[] picture() throws IOException
{
BufferedImage image = new BufferedImage(640, 320, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
graphics.setColor(new Color(0x12324a));
graphics.fillRect(0, 0, 640, 320);
graphics.setColor(new Color(0x55d6be));
graphics.fillRect(40, 180, 160, 90);
graphics.fillRect(240, 130, 160, 140);
graphics.fillRect(440, 95, 160, 175);
graphics.setColor(Color.WHITE);
graphics.setFont(new Font("SansSerif", Font.BOLD, 26));
graphics.drawString("Northstar | Growth plan", 40, 60);
graphics.dispose();
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
ImageIO.write(image, "png", bytes);
return (bytes.toByteArray());
}
/*******************************************************************************
** A small, valid PDF with a proper cross-reference table, generated locally.
*******************************************************************************/
private static byte[] pdf() throws IOException
{
String content = "BT /F1 18 Tf 50 740 Td (Northstar Outfitters - SO-2026-1001) Tj 0 -35 Td (Order confirmation: USD 18,750.00) Tj ET\n";
List<String> objects = List.of("<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", "<< /Length " + content.length() + " >>\nstream\n" + content + "endstream");
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
bytes.write("%PDF-1.4\n".getBytes(StandardCharsets.US_ASCII));
List<Integer> offsets = new ArrayList<>();
for(int i = 0; i < objects.size(); i++)
{
offsets.add(bytes.size());
bytes.write(((i + 1) + " 0 obj\n" + objects.get(i) + "\nendobj\n").getBytes(StandardCharsets.US_ASCII));
}
int start = bytes.size();
bytes.write("xref\n0 6\n0000000000 65535 f \n".getBytes(StandardCharsets.US_ASCII));
for(Integer offset : offsets)
{
bytes.write(String.format(java.util.Locale.ROOT, "%010d 00000 n \n", offset).getBytes(StandardCharsets.US_ASCII));
}
bytes.write(("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n" + start + "\n%%EOF\n").getBytes(StandardCharsets.US_ASCII));
return (bytes.toByteArray());
}
/*******************************************************************************
** Adapt the attachment HTTP test's fake to real local downloads. Opaque expiring
** links are issued only after AttachmentsService checks parent access.
*******************************************************************************/
public static class MemoryStorage extends AttachmentStorage
{
private static final Map<String, StoredFile> objects = new ConcurrentHashMap<>();
private static final Map<String, Download> downloads = new ConcurrentHashMap<>();
/*******************************************************************************
** Retain MIME type with the bytes for browser previews.
*******************************************************************************/
private record StoredFile(byte[] bytes, String contentType) {}
/*******************************************************************************
** Capability URLs expire just like the object-storage URLs they substitute.
*******************************************************************************/
private record Download(String key, String filename, Instant expires) {}
/*******************************************************************************
** The service enforces this cap while reading the upload stream.
*******************************************************************************/
@Override
public long maxBytes(String name)
{
return (5 * 1024 * 1024);
}
/*******************************************************************************
** Same bounded-stream storage approach as CollaborationApiHttpTest.MemoryStorage.
*******************************************************************************/
@Override
public void put(String name, PutRequest request)
{
try
{
objects.put(request.getKey(), new StoredFile(request.getStream().readAllBytes(), request.getContentType()));
}
catch(IOException e)
{
throw (new IllegalStateException("Cannot store demo attachment", e));
}
}
/*******************************************************************************
** Deleting the object also makes any outstanding download links unusable.
*******************************************************************************/
@Override
public void delete(String name, String key)
{
objects.remove(key);
}
/*******************************************************************************
** Issue a local URL on the configured demo port after the access check.
*******************************************************************************/
@Override
public URL presign(String name, PresignGet request)
{
downloads.entrySet().removeIf(entry -> entry.getValue().expires().isBefore(Instant.now()));
String token = UUID.randomUUID().toString();
downloads.put(token, new Download(request.getKey(), request.getDownloadAs(), Instant.now().plus(request.getTtl())));
try
{
return (URI.create("http://localhost:" + System.getProperty("collab.dev.port", "8098") + "/demo-files/" + token).toURL());
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
/*******************************************************************************
** Serve only issued capabilities; never expose an object-key listing.
*******************************************************************************/
static void routes(JavalinConfig config)
{
config.routes.get("/demo-files/{token}", context ->
{
Download download = downloads.get(context.pathParam("token"));
StoredFile file = download == null ? null : objects.get(download.key());
if(download == null || !download.expires().isAfter(Instant.now()) || file == null)
{
context.status(404);
return;
}
context.contentType(file.contentType() == null ? "application/octet-stream" : file.contentType());
context.header("Cache-Control", "no-store");
context.header("X-Content-Type-Options", "nosniff");
context.header("Content-Disposition", download.filename() == null ? "inline" : "attachment; filename*=UTF-8''" + java.net.URLEncoder.encode(download.filename(), StandardCharsets.UTF_8).replace("+", "%20"));
context.result(file.bytes());
});
}
/*******************************************************************************
** Keep the smoke test isolated from the other in-memory service tests.
*******************************************************************************/
static void clear()
{
objects.clear();
downloads.clear();
}
}
}
@@ -0,0 +1,344 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.devserver;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.collaboration.attachments.AttachmentsService;
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qbits.collaboration.recents.RecentsService;
import com.kingsrook.qbits.collaboration.sharing.SharingService;
import com.kingsrook.qbits.collaboration.tags.TagsService;
import com.kingsrook.qbits.collaboration.timeline.TimelineService;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import com.kingsrook.qqq.middleware.javalin.QJavalinImplementation;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Exercise the real demo seed, permissions, audit history and local file URLs.
*******************************************************************************/
class CollaborationDevServerTest
{
/*******************************************************************************
** Build without frontend assets, then smoke the same server on a free port.
*******************************************************************************/
@Test
void seededDemoValidatesAndServesCollaboration() throws Exception
{
String oldUser = System.getProperty("collab.dev.user");
String oldPort = System.getProperty("collab.dev.port");
QInstance instance = null;
QApplicationJavalinServer server = null;
MemoryRecordStore.getInstance().reset();
CollaborationDevServer.MemoryStorage.clear();
try(HttpClient client = HttpClient.newHttpClient())
{
System.setProperty("collab.dev.user", "alice");
int port;
try(ServerSocket socket = new ServerSocket(0))
{
port = socket.getLocalPort();
}
instance = CollaborationDevServer.buildInstance(port);
CollaborationDevServer.seed(instance);
new QInstanceValidator().validate(instance);
assertThat(rows("person")).hasSize(25);
assertThat(rows("sales_order")).hasSize(40);
assertThat(rows("person")).anySatisfy(row ->
{
assertThat(row.getValueInteger("id")).isEqualTo(1);
assertThat(row.getValueString("cf_preferred_contact")).isEqualTo("video");
});
assertThat(rows("sales_order")).allSatisfy(row -> assertThat(row.getValueString("cf_purchase_order_no")).startsWith("PO-"));
assertThat(instance.getTable("person").getField("cf_preferred_contact").getBackendStorage()).isInstanceOf(JsonPathStorage.class);
assertThat(instance.getTable("sales_order").getField("cf_purchase_order_no").getBackendStorage()).isInstanceOf(JsonPathStorage.class);
assertThat(instance.getTable("sales_order").getSearchFields()).contains("cf_purchase_order_no");
for(String table : List.of("person", "sales_order"))
{
var features = RecordFeaturesResolver.resolve(instance, instance.getTable(table)).getFeatures();
for(RecordFeature feature : RecordFeature.values())
{
assertThat(features.get(feature).getEnabled()).as(table + " " + feature).isTrue();
}
assertThat(new TagsService().tagsFor(table, 1)).hasSize(4);
}
assertThat(new TagsService().mode("person")).isEqualTo("FREE");
assertThat(new TagsService().mode("sales_order")).isEqualTo("APPROVED_ONLY");
assertThat(FavoritesService.list()).hasSize(6);
assertThat(RecentsService.list(20)).hasSize(6);
assertThat(new TimelineService().timeline("person", 1, null, 100, null).items())
.anySatisfy(item -> assertThat(item.details()).anySatisfy(detail ->
{
assertThat(detail.fieldName()).isEqualTo("title");
assertThat(detail.oldValue()).isEqualTo("Purchasing Director");
assertThat(detail.newValue()).isEqualTo("VP of Purchasing");
}));
assertThat(new TimelineService().timeline("person", 1, null, 100, null).items()).extracting(item -> item.summary())
.anyMatch(summary -> summary.startsWith("Edited by ") && summary.contains("Title")).noneMatch(summary -> summary.startsWith("Record was"));
assertThat(new SharingService().preview("person", 1, List.of("bob")).getFirst().canRead()).isTrue();
assertThat(new SharingService().preview("sales_order", 1, List.of("bob")).getFirst().canRead()).isFalse();
QContext.setQSession(CollaborationDevServer.session("admin"));
assertThat(rows(CollaborationTables.USER)).hasSize(3);
assertThat(rows(CollaborationTables.FIELD_DEFINITION)).hasSize(2).allSatisfy(row -> assertThat(row.getValueString("status")).isEqualTo("ACTIVE"));
assertThat(rows(CollaborationTables.TAG)).hasSize(8);
assertThat(rows(CollaborationTables.ATTACHMENT)).hasSize(3);
assertThat(rows(CollaborationTables.COMMENT)).hasSize(6).anySatisfy(row -> assertThat(row.getValue("resolvedAt")).isNotNull());
assertThat(rows(CollaborationTables.MENTION)).hasSize(3);
assertThat(rows(CollaborationTables.TASK)).hasSize(4).anySatisfy(row ->
{
assertThat(row.getValueString("status")).isEqualTo("OPEN");
assertThat(row.getValueInstant("dueAt")).isBefore(Instant.now());
});
assertThat(rows(CollaborationTables.SHARE)).hasSize(2);
assertThat(rows(CollaborationTables.RECORD_ASSIGNMENT)).hasSize(2);
assertThat(rows(CollaborationTables.ACCESS_REQUEST)).singleElement().satisfies(row -> assertThat(row.getValueString("status")).isEqualTo("OPEN"));
assertThat(rows("auditDetail")).isNotEmpty();
for(String user : CollaborationDevServer.USERS)
{
System.setProperty("collab.dev.user", user);
QSession mock = new MockAuthenticationModule().createSession(instance, Map.of("sessionUUID", UUID.randomUUID().toString()));
assertThat(mock.getUser().getIdReference()).isEqualTo(user);
assertThat(mock.getPermissions()).isEqualTo(CollaborationDevServer.session(user).getPermissions());
assertThat(mock.getUser().getFullName()).isEqualTo(CollaborationDevServer.session(user).getUser().getFullName()).isNotEqualTo("Anonymous").isNotEqualTo("John Smith");
assertThat(mock.getValuesForFrontend()).containsEntry("user", CollaborationDevServer.session(user).getValuesForFrontend().get("user"));
}
assertThatThrownBy(() -> CollaborationDevServer.session("unknown")).isInstanceOf(IllegalArgumentException.class);
System.setProperty("collab.dev.user", "alice");
System.setProperty("collab.dev.port", Integer.toString(port));
server = CollaborationDevServer.server(instance, port, false);
server.start();
String base = "http://localhost:" + port;
assertThat(get(client, base + "/qqq/v1/version").statusCode()).isEqualTo(200);
var response = get(client, base + "/qqq/v1/qbit/collaboration/records/person/1/comments");
assertThat(response.statusCode()).isEqualTo(200);
assertThat(new ObjectMapper().readTree(response.body()).get("threads")).hasSize(3);
QContext.init(instance, CollaborationDevServer.session("alice"));
assertThat(new SharingService().link("person", 1).url()).isEqualTo("http://localhost:" + port + "/app/person/1");
AttachmentsService attachments = new AttachmentsService();
var png = attachments.list("person", 1).getFirst();
var image = get(client, attachments.downloadUrl(png.id(), true).toString());
assertThat(image.statusCode()).isEqualTo(200);
assertThat(image.headers().firstValue("Content-Type").orElseThrow()).startsWith("image/png");
assertThat(javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(image.body()))).isNotNull();
var pdf = attachments.list("sales_order", 1).getFirst();
var document = get(client, attachments.downloadUrl(pdf.id(), false).toString());
assertThat(document.statusCode()).isEqualTo(200);
assertThat(new String(document.body(), java.nio.charset.StandardCharsets.US_ASCII)).startsWith("%PDF-1.4");
assertThat(document.headers().firstValue("Content-Disposition").orElseThrow()).contains("attachment", "order-confirmation.pdf");
System.setProperty("collab.dev.user", "bob");
assertThat(get(client, base + "/qqq/v1/qbit/collaboration/records/person/1/comments").statusCode()).isEqualTo(200);
assertThat(get(client, base + "/qqq/v1/qbit/collaboration/records/sales_order/1/comments").statusCode()).isEqualTo(404);
}
finally
{
if(server != null)
{
server.stop();
}
if(instance != null)
{
PresenceRegistry.close(instance);
RealtimeBus.close(instance);
}
QContext.clear();
QJavalinImplementation.setSessionCookieHttpOnly(false);
MemoryRecordStore.getInstance().reset();
CollaborationDevServer.MemoryStorage.clear();
restore("collab.dev.user", oldUser);
restore("collab.dev.port", oldPort);
}
}
/*******************************************************************************
** Two browsers see two users: ?user= is remembered in a cookie, the session carries
** the person's real name, and without a choice the JVM default still applies.
*******************************************************************************/
@Test
void eachBrowserSessionChoosesItsOwnUser() throws Exception
{
String oldUser = System.getProperty("collab.dev.user");
MemoryRecordStore.getInstance().reset();
CollaborationDevServer.MemoryStorage.clear();
QInstance instance = null;
QApplicationJavalinServer server = null;
try(HttpClient client = HttpClient.newHttpClient())
{
System.setProperty("collab.dev.user", "admin");
int port;
try(ServerSocket socket = new ServerSocket(0))
{
port = socket.getLocalPort();
}
instance = CollaborationDevServer.buildInstance(port);
CollaborationDevServer.seed(instance);
server = CollaborationDevServer.server(instance, port, false);
server.start();
String base = "http://localhost:" + port;
var chosen = get(client, base + "/qqq/v1/version?createMockSession=true&user=bob");
assertThat(chosen.headers().allValues("Set-Cookie")).anySatisfy(cookie -> assertThat(cookie).startsWith("collab_dev_user=bob"));
assertThat(get(client, base + "/qqq/v1/version?user=nobody").headers().allValues("Set-Cookie")).noneMatch(cookie -> cookie.startsWith("collab_dev_user"));
var manage = client.send(HttpRequest.newBuilder(URI.create(base + "/qqq/v1/manageSession")).timeout(Duration.ofSeconds(20))
.header("Cookie", "collab_dev_user=bob").header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"accessToken\":\"anonymous\"}")).build(), HttpResponse.BodyHandlers.ofString());
assertThat(new ObjectMapper().readTree(manage.body()).at("/values/user/name").asText()).as(manage.body()).isEqualTo("Bob Chen");
assertThat(commenter(client, base, "bob")).containsExactly("bob", "Bob Chen");
assertThat(commenter(client, base, "alice")).containsExactly("alice", "Alice Morgan");
assertThat(commenter(client, base, "admin")).containsExactly("admin", "Demo Administrator");
assertThat(commenter(client, base, null)).as("no choice falls back to the collab.dev.user default").containsExactly("admin", "Demo Administrator");
assertThat(commenter(client, base, "nobody")).as("an unknown cookie falls back to the default").containsExactly("admin", "Demo Administrator");
assertThat(CollaborationDevServer.selectedUser()).isEqualTo("admin");
}
finally
{
if(server != null)
{
server.stop();
}
if(instance != null)
{
PresenceRegistry.close(instance);
RealtimeBus.close(instance);
}
QContext.clear();
QJavalinImplementation.setSessionCookieHttpOnly(false);
MemoryRecordStore.getInstance().reset();
CollaborationDevServer.MemoryStorage.clear();
restore("collab.dev.user", oldUser);
}
}
/*******************************************************************************
** Post a comment as the browser holding the given user cookie; return who the server says wrote it.
*******************************************************************************/
private static List<String> commenter(HttpClient client, String base, String cookieUser) throws Exception
{
String cookie = "sessionUUID=" + UUID.randomUUID() + (cookieUser == null ? "" : "; collab_dev_user=" + cookieUser);
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/qqq/v1/qbit/collaboration/records/person/1/comments")).timeout(Duration.ofSeconds(20))
.header("Cookie", cookie).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"body\":\"Who am I?\"}")).build(), HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
var json = new ObjectMapper().readTree(response.body());
return (List.of(json.get("authorUserId").asText(), json.get("authorDisplayName").asText()));
}
/*******************************************************************************
** The demo places Collaboration inside Admin, and the admin demo user can navigate to it.
*******************************************************************************/
@Test
void collaborationIsInTheAppTreeUnderAdmin() throws Exception
{
MemoryRecordStore.getInstance().reset();
CollaborationDevServer.MemoryStorage.clear();
QInstance instance = CollaborationDevServer.buildInstance();
try
{
QContext.init(instance, CollaborationDevServer.session("admin"));
var tree = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction()
.execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput()).getAppTree();
var admin = tree.stream().filter(node -> node.getName().equals("admin")).findFirst();
assertThat(admin).as("admin sees the Admin app").isPresent();
assertThat(admin.get().getChildren()).extracting(node -> node.getName()).contains("collaboration");
assertThat(tree).extracting(node -> node.getName()).doesNotContain("collaboration");
QContext.init(instance, CollaborationDevServer.session("alice"));
var aliceTree = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction()
.execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput()).getAppTree();
assertThat(aliceTree).extracting(node -> node.getName()).contains("sales").doesNotContain("admin", "collaboration");
}
finally
{
PresenceRegistry.close(instance);
RealtimeBus.close(instance);
QContext.clear();
MemoryRecordStore.getInstance().reset();
}
}
/*******************************************************************************
** Read through normal permissions, including the seeded admin-only tables.
*******************************************************************************/
private static List<QRecord> rows(String table) throws Exception
{
return (new QueryAction().execute(new QueryInput(table)).getRecords());
}
/*******************************************************************************
** Each request gets a fresh MOCK session with the currently selected user.
*******************************************************************************/
private static HttpResponse<byte[]> get(HttpClient client, String url) throws Exception
{
return (client.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(20))
.header("Cookie", "sessionUUID=" + UUID.randomUUID()).GET().build(), HttpResponse.BodyHandlers.ofByteArray()));
}
/*******************************************************************************
** Do not leak demo properties into the rest of the test suite.
*******************************************************************************/
private static void restore(String name, String value)
{
if(value == null)
{
System.clearProperty(name);
}
else
{
System.setProperty(name, value);
}
}
}
@@ -0,0 +1,281 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.email;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.NoOpNotifier;
import com.kingsrook.qbits.collaboration.notify.Notifications;
import com.kingsrook.qbits.collaboration.sharing.SharingService;
import com.kingsrook.qbits.collaboration.tasks.TasksService;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiMessage;
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Collaboration notifications through a real qbit-email outbox (memory backend).
*******************************************************************************/
class EmailCollaborationNotifierTest extends PersonalServiceFixture
{
private static final Map<String, QSession> sessions = new HashMap<>();
/*******************************************************************************
** Sessions for the recipients, so access is re-checked as them.
*******************************************************************************/
public static class TestSessions implements UserSessionProviderInterface
{
/*******************************************************************************
** The recipient own session.
*******************************************************************************/
@Override
public Optional<QSession> sessionForUser(String user)
{
return (Optional.ofNullable(sessions.get(user)));
}
}
/*******************************************************************************
** Recipients bob and eve can read the document and have addresses.
*******************************************************************************/
@BeforeEach
void configure() throws Exception
{
sessions.clear();
for(String user : List.of("alice", "bob", "eve"))
{
sessions.put(user, new QSession().withUser(new QUser().withIdReference(user)).withPermissions("document.read", "document.task").withSecurityKeyValue("tenant", "a"));
}
config.withUserSessionProvider(new QCodeReference(TestSessions.class));
CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "bob").withValue("displayName", "Bob").withValue("email", "bob@example.org"));
CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "eve").withValue("displayName", "Eve").withValue("email", "eve@example.org"));
}
/*******************************************************************************
** Host fixture wiring only: G1 scans production classes and sources. Create
** the provider through standard QQQ actions with the documented permissions.
*******************************************************************************/
private void withEmail() throws Exception
{
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withPublicBaseUrl("https://app.example.org")
.withSecretResolver(name -> "k")).produce(instance);
QContext.getQSession().withPermissions("email.view", "email.send", "email.manage", "email.admin", "email.hasAccess");
QRecord provider = new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP").withValue("host", "127.0.0.1")
.withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true);
assertThat(new InsertAction().execute(new InsertInput("email_provider").withRecords(List.of(provider))).getRecords())
.singleElement().satisfies(row -> assertThat(row.getErrors()).isNullOrEmpty());
}
/*******************************************************************************
** Inspect rendered test content through the standard, permission-checked
** table action. The public API deliberately exposes only message metadata.
*******************************************************************************/
private List<QRecord> messages() throws Exception
{
return (new QueryAction().execute(new QueryInput("email_message").withFilter(new QQueryFilter())
.withShouldFetchHeavyFields(true).withShouldOmitHiddenFields(false)).getRecords());
}
/*******************************************************************************
** A notification of a category for a recipient.
*******************************************************************************/
private CollaborationNotification notification(String recipient, CollaborationNotification.Category category, String eventId)
{
return (new CollaborationNotification(recipient, "You were mentioned in a comment", "secret comment body", category, "document", "1",
"https://app.example.org/app/document/1", eventId, "alice"));
}
/*******************************************************************************
** With the email qbit present and no notifier configured, a mention becomes one
** NOTIFICATION message with the key, link and record, and no comment body; a
** repeat is a duplicate.
*******************************************************************************/
@Test
void mentionQueuesOneMessageAndRepeatIsDuplicate() throws Exception
{
withEmail();
assertThat(config.getNotifier()).isNull();
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
assertThat(messages()).singleElement().satisfies(message ->
{
assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION");
assertThat(message.getValueString("toAddresses")).contains("bob@example.org").doesNotContain("eve@example.org");
assertThat(message.getValueString("idempotencyKey")).isEqualTo("collaboration:mention:7:bob");
assertThat(message.getValueString("linkedTableName")).isEqualTo("document");
assertThat(message.getValueString("linkedRecordId")).isEqualTo("1");
assertThat(message.getValueString("senderQbit")).isEqualTo("qbit-collaboration");
assertThat(message.getValueString("subject")).contains("alice mentioned you on First document");
assertThat(message.getValueString("subject") + message.getValueString("textBody") + message.getValueString("htmlBody")).doesNotContain("secret comment body");
assertThat(message.getValueString("htmlBody")).contains("https://app.example.org/app/document/1");
});
assertThat(EmailApi.getMessageByIdempotencyKey("collaboration:mention:7:bob")).hasValueSatisfying(message ->
{
assertThat(message.status()).isEqualTo("QUEUED");
assertThat(message.category()).isEqualTo(EmailApiCategory.NOTIFICATION);
assertThat(message.templateName()).isEqualTo("collaboration_mention");
assertThat(message.linkedTableName()).isEqualTo("document");
assertThat(message.linkedRecordId()).isEqualTo("1");
});
}
/*******************************************************************************
** Restricted record labels never enter email subjects or rendered bodies.
*******************************************************************************/
@Test
void maskedLabelUsesGenericEmailText() throws Exception
{
withEmail();
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK));
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
assertThat(messages()).singleElement().satisfies(message ->
{
assertThat(message.getValueString("subject")).contains("alice mentioned you on a record in Documents");
assertThat(message.getValueString("subject") + message.getValueString("textBody") + message.getValueString("htmlBody"))
.doesNotContain("First document", "secret comment body");
});
}
/*******************************************************************************
** Each recipient gets their own message; the share and task kinds use their
** own templates and key kinds; follow digests send no email.
*******************************************************************************/
@Test
void oneMessagePerRecipientAndKinds() throws Exception
{
withEmail();
Notifications.send(notification("bob", CollaborationNotification.Category.SHARE, "3"));
Notifications.send(notification("eve", CollaborationNotification.Category.SHARE, "3"));
Notifications.send(notification("bob", CollaborationNotification.Category.TASK, "9:remindAt"));
Notifications.send(notification("bob", CollaborationNotification.Category.FOLLOW, "1"));
assertThat(messages()).extracting(m -> m.getValueString("idempotencyKey"))
.containsExactlyInAnyOrder("collaboration:share:3:bob", "collaboration:share:3:eve", "collaboration:task:9:remindAt:bob");
assertThat(messages()).allSatisfy(m -> assertThat(m.getValueString("toAddresses")).doesNotContain(","));
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("collaboration:", 10)).extracting(EmailApiMessage::templateName)
.contains("collaboration_share", "collaboration_task_reminder");
}
/*******************************************************************************
** The configured resolver replaces the directory; no address means no email.
*******************************************************************************/
@Test
void resolverAndMissingAddress() throws Exception
{
withEmail();
config.withRecipientEmailResolver(user -> "eve".equals(user) ? "other@example.org" : null);
Notifications.send(notification("eve", CollaborationNotification.Category.MENTION, "1"));
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "2"));
assertThat(messages()).singleElement().satisfies(m -> assertThat(m.getValueString("toAddresses")).contains("other@example.org"));
}
/*******************************************************************************
** Real shares and task assignments carry the event id and actor into the email.
*******************************************************************************/
@Test
void sharesAndTasksThroughTheServices() throws Exception
{
withEmail();
new SharingService().share("document", 1, List.of("bob"), "private message");
new TasksService().create("document", 1, "Private task title", "private notes", "bob", null, null);
assertThat(messages()).hasSize(2).allSatisfy(m ->
{
assertThat(m.getValueString("subject") + m.getValueString("textBody") + m.getValueString("htmlBody")).doesNotContain("private", "Private");
assertThat(m.getValueString("idempotencyKey")).matches("collaboration:(share|task):\\d+:bob");
});
assertThat(messages().getFirst().getValueString("subject")).startsWith("alice shared");
}
/*******************************************************************************
** Without the email qbit in the instance nothing is sent and no receipt runs.
*******************************************************************************/
@Test
void withoutEmailQbitNothingHappens() throws Exception
{
boolean[] receipt = {false};
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"), () -> receipt[0] = true);
assertThat(receipt[0]).isFalse();
assertThat(EmailCollaborationNotifier.isAvailable(instance)).isFalse();
}
/*******************************************************************************
** A configured notifier always wins over the email default.
*******************************************************************************/
@Test
void configuredNotifierWins() throws Exception
{
withEmail();
config.withNotifier(new QCodeReference(NoOpNotifier.class));
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
assertThat(messages()).isEmpty();
}
}
@@ -33,11 +33,8 @@ import com.kingsrook.qbits.collaboration.CollaborationSchema;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
@@ -55,6 +52,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@@ -117,6 +116,26 @@ class RecentsServiceTest extends PersonalServiceFixture
/*******************************************************************************
** A refreshed recent discards its old cached label after access is restricted.
*******************************************************************************/
@Test
void refreshClearsLabelWhenFieldAccessIsRestricted() throws Exception
{
at(START);
RecentsService.recordView("RECORD", "document", 1);
assertThat(rows().getFirst().getValueString("label")).isEqualTo("First document");
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK));
at(START.plusSeconds(60));
RecentsService.recordView("RECORD", "document", 1);
assertThat(rows().getFirst().getValueString("label")).isNull();
assertThat(rows().getFirst().getValueInteger("viewCount")).isEqualTo(2);
}
/*******************************************************************************
** Null ids also upsert; visibility is checked even inside the throttle window.
*******************************************************************************/
@@ -257,27 +276,6 @@ class RecentsServiceTest extends PersonalServiceFixture
/*******************************************************************************
** Counts use the current user's owner lock and admins see real distinct users.
*******************************************************************************/
@Test
void homeSummaryCounts() throws Exception
{
at(START);
login("alice", CollaborationPermissions.VIEW);
FavoritesService.star("TABLE", "document", null);
FavoritesService.star("RECORD", "document", "1");
RecentsService.recordView("TABLE", "document", null);
login("bob", CollaborationPermissions.VIEW);
FavoritesService.star("TABLE", "document", null);
RecentsService.recordView("TABLE", "document", null);
assertThat(CollaborationHome.render(START)).contains("<h3>Favorites</h3><p>Rows: 1</p><p>Distinct users: 1", "<h3>Recently viewed</h3><p>Rows: 1</p><p>Distinct users: 1");
admin();
assertThat(CollaborationHome.render(START)).contains("<h3>Favorites</h3><p>Rows: 3</p><p>Distinct users: 2", "<h3>Recently viewed</h3><p>Rows: 2</p><p>Distinct users: 2");
}
/*******************************************************************************
** Freeze the service clock, including exact boundary cases.
*******************************************************************************/