Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3182e2cbc
|
||
|
|
8f348daf32
|
||
|
|
284a7ac5b6
|
||
|
|
221ec83457
|
||
|
|
0caaa3717e | ||
|
|
464db76bc3 | ||
|
|
19a06166f5 | ||
|
|
61060e2793
|
||
|
|
4e88ed8303 | ||
|
|
7a7b804106
|
||
|
|
4c95151a86 | ||
|
|
41bbf2c428
|
||
|
|
2f6be765f2
|
||
|
|
92dd901409 | ||
|
|
6a4ea584f5
|
||
|
|
03b3bbaca2 | ||
|
|
31ce528064
|
||
|
|
90b3956cf7 | ||
|
|
e4f92192c6
|
||
|
|
675d30568e | ||
|
|
3df535453e
|
||
|
|
d302f1dc1f | ||
|
|
58c87b0378
|
||
|
|
e5647e6020
|
||
|
|
3d2a1ded72
|
||
|
|
662a9f1a6d | ||
|
|
e014c00428
|
||
|
|
ea0b74f94c | ||
|
|
e051c74273
|
||
|
|
680fd5fa02 | ||
|
|
1e0a5773bb | ||
|
|
83b253a94f |
No files matched your search
+13
-57
@@ -1,60 +1,16 @@
|
||||
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.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only:
|
||||
- /^v\d+\.\d+\.\d+$/
|
||||
- /publish.*/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
feature: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit Collaboration**: record collaboration for QQQ. Phase a implements feature settings, people tables and the app home. Design: [QRun-IO/qqq#1136](https://github.com/QRun-IO/qqq/pull/1136) (`docs/COLLABORATION.md`). Phases b to i are planned.
|
||||
**QBit Collaboration**: record collaboration for QQQ. The implemented feature set and published candidate deployment contract are documented in README.md and docs/CANDIDATE-RELEASE.md. Historical phase design: docs/DESIGN.md.
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -23,7 +23,7 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
- The home reads real data with the current session.
|
||||
- People are a service-managed directory; direct user writes are denied.
|
||||
- Feature settings are audited at FIELD level; writes require `collaboration.admin`.
|
||||
- Runtime override application and record feature services await the core contracts.
|
||||
- Runtime overrides and record feature services use the coordinated core contracts; preserve authorization, transaction boundaries and recipient field filtering.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
@@ -42,12 +42,12 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Read pom.xml for the current build parent. The default-active qqq-snapshot profile pins the coordinated published core BOM; docs/CANDIDATE-RELEASE.md records compatible Redis, session-store and frontend versions.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
- CircleCI qqq-orb 0.6.8.
|
||||
- CircleCI is centrally owned through .qqq-ci.yml and the canonical setup caller. Keep both declarations aligned; validate with the selected orb canonical validator as well as CircleCI schema validation.
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- A `release/*` branch publishes RCs (the orb bumps the version).
|
||||
- A `v*` tag publishes the release.
|
||||
- Stable release tags and feature publish tags follow the manifest policy. Feature snapshots use the existing publish-tag automation; keep generated versions within the framework length limit. Do not treat snapshot publication as GA or production deployment.
|
||||
+26
-1
@@ -2,16 +2,41 @@
|
||||
|
||||
All notable changes are documented here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic versioning.
|
||||
|
||||
## Coordinated candidate — 2026-10-21
|
||||
|
||||
Published `0.1.0-collab-8f348da-SNAPSHOT` with core `4.1.0-collab-33464d37e-SNAPSHOT` and Redis `0.1.0-redis-7fe5492-SNAPSHOT`. See [candidate release guide](docs/CANDIDATE-RELEASE.md) for the paired frontend/session-store and migration requirements.
|
||||
|
||||
- Added server-issued, ordered presence leases, shared Redis record/table state, explicit unavailable/expired responses and recipient-aware field filtering.
|
||||
- Preserved omitted assignment roles and prevented fallback ownership from overriding configured native ownership.
|
||||
- Added delivery acknowledgement semantics, transaction-aware attachment compensation and authorized mention-read flow.
|
||||
- Verified 177 release-source tests, coverage, remote CI, published-artifact consumer resolution and live cross-node presence/admin/health with the paired frontend.
|
||||
|
||||
## [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.
|
||||
@@ -38,7 +63,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`.
|
||||
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
# QBit Collaboration
|
||||
|
||||
Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and core-integrated tag filtering/search. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g add the custom-field designer and ephemeral replicated presence. Runtime overrides and TAGS mode settings resolve through core.
|
||||
Record collaboration for QQQ: attachments, tags, custom fields, comments and mentions, presence, sharing, favorites, follow, timeline, tasks, assignments and My Work. Feature settings, people directories, permissions and the app home use the standard QQQ framework. The coordinated candidate includes issued presence leases with optional shared Redis, recipient-aware field filtering, partial assignment updates, delivery acknowledgements and transaction-aware attachment cleanup.
|
||||
|
||||
Published versions, verified behavior and deployment requirements: [Coordinated candidate](docs/CANDIDATE-RELEASE.md). Historical phase references below describe implementation history, not the current release sequence.
|
||||
|
||||
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,16 +30,33 @@ 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 the locally installed
|
||||
QQQ `4.1.0-SNAPSHOT` from develop. No database, Docker or MinIO is needed. It binds
|
||||
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`:
|
||||
@@ -116,6 +152,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 |
|
||||
@@ -199,7 +244,7 @@ Uploads check the parent and `attach`, count bytes and compute SHA-256 while str
|
||||
|
||||
File names are reduced to a safe basename (maximum 255 characters). Object keys are `attachments/{table}/{recordId}/{uuid}/{fileName}`; table/id segments are URL-encoded to protect string primary keys containing slashes. Renames change the display name and subsequent download filename, retaining the original storage key. `AttachmentEntry` returns display metadata, creation time and `canDelete` (author or admin); it omits bucket/key and signed URLs. Lists are newest first. Changing or deleting an own row also rechecks `attach`; admins may moderate another author's row but must still see its parent. A batch authorizes all selected rows before deleting its first object.
|
||||
|
||||
**Permission model.** The panel service checks the parent with an enforced `GetAction` under the caller's session. Missing, unreadable and locked parents produce `QNotFoundException("Not found")`. Upload requires `<permissionBaseName>.attach` or table EDIT plus a visible parent. This is the explicitly temporary approximation of write-lock checks; distinct WRITE-only parent locks are not enforced until core's record-reference helper is released. Explicit `attach` grants work without EDIT. Collaboration tables cannot themselves be parents.
|
||||
**Permission model.** The panel service checks the parent with an enforced `GetAction` under the caller's session. Missing, unreadable and locked parents produce `QNotFoundException("Not found")`. Upload requires `<permissionBaseName>.attach` or table EDIT plus a visible parent. Core RecordReferenceSecurityHelper enforces parent visibility, and RecordFeaturePermissions applies WRITE locks for implied grants. Explicit `attach` grants work without EDIT. Collaboration tables cannot themselves be parents.
|
||||
|
||||
The admin Attachments table additionally requires `collaboration.view` (or `collaboration.admin`). The panel service bypasses that browse grant only after its own parent/feature/ownership checks. Direct table insert/update/delete is denied; use the service and record action so storage and rows remain coordinated. Storage operations use `ObjectStorage.asSystem("collaboration", runId).bucket(alias)` after these checks, preserving calling-qbit/initiating-user operation logging without requiring users to hold `object_storage.*` grants.
|
||||
|
||||
@@ -209,7 +254,7 @@ Core reference metadata now restricts parent tables/features and enforces parent
|
||||
|
||||
**Actions and cleanup.** `Delete attachments` is a standard streamed ETL record action with Review/Result counts, skipped unauthorized rows, permission rechecks at execution and per-row failure summaries. It is not in navigation. Soft deletion follows the bucket's versioning/trash; without either, deletion is permanent. There is deliberately no **Attach files** table action: uploads come through the API/panel. The current browse action requires `collaboration.view`; the panel may call the service without that grant.
|
||||
|
||||
After a parent deletion commits, trusted host code can call `new AttachmentCleanup().onParentsDeleted(table, ids)`. It deletes objects and rows in batches and is retryable. It cannot check an already-deleted parent and must never be exposed as a user endpoint. Wiring to core's `RecordReferenceCleanupInterface` waits for its release. UI API endpoints use the merged #1128 extension point from the locally installed snapshot; direct/presigned upload endpoints and realtime events remain future integrations. Upload/delete now write FILE_ADDED/FILE_REMOVED activity. The table declares `fileName`/`description` search fields and uses normal QQQ writes, so host-configured record-change/ESB listeners receive events; this qbit does not install an ESB publication or Quick Search provider.
|
||||
After a parent deletion commits, trusted host code can call `new AttachmentCleanup().onParentsDeleted(table, ids)`. It deletes objects and rows in batches and is retryable. It cannot check an already-deleted parent and must never be exposed as a user endpoint. CollaborationCleanup implements core's `RecordReferenceCleanupInterface`. The versioned UI API provides multipart upload and authorized download/preview URL responses using the coordinated published core. Outer transactions defer object deletion until commit; uploads require confirmed rollback-callback support. Durable crash/retry qualification remains tracked in QQQ#1289. Upload/delete now write FILE_ADDED/FILE_REMOVED activity. The table declares `fileName`/`description` search fields and uses normal QQQ writes, so host-configured record-change/ESB listeners receive events; this qbit does not install an ESB publication or Quick Search provider.
|
||||
|
||||
Tests use real QQQ memory actions and record locks plus an in-memory `AttachmentStorage` adapter. Object storage RC.2 exposes no fake bucket; its provider conformance suite uses MinIO/Testcontainers. This suite requires no Docker and does not claim MinIO/S3 conformance.
|
||||
|
||||
@@ -296,7 +341,7 @@ The guardrail allowlist properties retain the object-storage API and `*-api` art
|
||||
2. For PostgreSQL, generate `CollaborationSchema.postgresScript(config)` and apply it with the host's migration tool. It creates tables and unique indexes, never executes DDL, and does not migrate existing schemas. QQQ owns date/default values on writes.
|
||||
3. Grant `collaboration.view` and `collaboration.admin` as appropriate; wire the host's audit persistence for FIELD audit events.
|
||||
4. Validate the assembled instance. Navigate to Collaboration for Comments, Attachments, Activity (admins), Feature Settings and People. People populate as signed-in users call collaboration services.
|
||||
5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. Favorites, recents, attachments, comments, follow and timeline services can be used now. The versioned UI API is enabled by default; host-table overlays remain separate and scheduled work is opt-in.
|
||||
5. Use the coordinated versions in docs/CANDIDATE-RELEASE.md, enable the desired record features in host metadata, and configure their dependencies. The versioned UI API is enabled by default; scheduled work remains opt-in. Apply the documented host registration and schema migrations before rollout.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -367,13 +412,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`.
|
||||
|
||||
@@ -435,9 +480,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.
|
||||
@@ -563,7 +610,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)
|
||||
@@ -593,9 +640,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)
|
||||
@@ -667,34 +714,23 @@ Archive drops the index concurrently while retaining values.
|
||||
There is no automatic migration receipt. The home shows definition/index status totals and the
|
||||
number of definitions with diagnostic messages.
|
||||
|
||||
## Presence (phase g)
|
||||
## Presence
|
||||
|
||||
Enable `RecordFeature.PRESENCE` on the table. The typed endpoints below
|
||||
`/qqq/v1/qbit/collaboration` are:
|
||||
Enable `RecordFeature.PRESENCE` on the relevant tables. For multiple web nodes, configure the shared Redis provider described in [REDIS-PRESENCE.md](docs/REDIS-PRESENCE.md). Use the paired versions and deployment requirements in [CANDIDATE-RELEASE.md](docs/CANDIDATE-RELEASE.md).
|
||||
|
||||
| Operation | Input |
|
||||
The shared provider uses server-issued authority:
|
||||
|
||||
| Operation | Behavior |
|
||||
| --- | --- |
|
||||
| `POST /presence` | `{connectionId, table, pk?, mode: "VIEWING"|"EDITING", fieldName?}` |
|
||||
| `DELETE /presence` | `{connectionId, table, pk?}` |
|
||||
| `GET /presence?table=...&pk=...` | Omit pk for a table snapshot |
|
||||
| `POST /presence/leases` | Authorizes the target and creates an invisible expiring reservation; returns its connectionId. |
|
||||
| `POST /presence/leases/renew` | Activates or renews an existing handle using an ordered sequence; includes viewing/editing mode and optional focused field. |
|
||||
| `DELETE /presence` | Performs ordered departure. A delayed renewal cannot reopen that handle. |
|
||||
| `GET /presence?table=...&pk=...` | Reads the authorized record snapshot; omit pk for a table snapshot. |
|
||||
|
||||
Session identity supplies userId and displayName. Every call rechecks table READ and record
|
||||
GetAction visibility. Disabled features and denied/missing parents return the same 404. A
|
||||
refused renewal removes the caller's previous lease. Connection ids are namespaced by user;
|
||||
they need not be active SSE connections, allowing polling clients to participate.
|
||||
All paths are under `/qqq/v1/qbit/collaboration`. Shared presence rejects legacy heartbeat upserts. Local legacy behavior remains for compatibility; local issued authority is suitable for a single node, not an HA replacement for shared Redis.
|
||||
|
||||
Renew on open/focus change and every 20 seconds. Leases expire after 45 seconds, with a one-second
|
||||
sweep and expiry checks on reads. The cap is 50,000 leases per pod: new targets are refused with
|
||||
a warning while existing leases can renew. Tabs merge per user; EDITING wins and the latest
|
||||
editor supplies focus. Nothing is persisted, including directory rows.
|
||||
Identity and tenant scope come from authenticated host context. Reads and renewals recheck permissions; recipient field filtering prevents focus-name disclosure. Tabs merge by user, editing takes precedence over viewing, and table snapshots omit focus. Redis stores presence metadata, never unsaved record contents.
|
||||
|
||||
Every heartbeat/leave replicates over core's bus on `instance:presence`, a channel refused by
|
||||
core client subscriptions. `presence.changed` is emitted to record and table channels. Table
|
||||
snapshots aggregate users without record ids or focused field names; those stay on record
|
||||
channels. Configure core's ESB bus for multiple pods. Delivery is best effort; clients renew and
|
||||
refetch after reconnecting.
|
||||
HTTP409 with `PRESENCE_LEASE_EXPIRED` requires a fresh lease; unavailable shared storage returns503 and an explicit unavailable UI state. It never silently falls back to an empty room or local state. The frontend releases leases on hide/unmount and preserves drafts through presence changes.
|
||||
|
||||
The initial overlay refresh starts the registry on every pod, including those without local
|
||||
viewers. Endpoints also initialize it lazily. At shutdown call `PresenceRegistry.close(instance)`
|
||||
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user counts
|
||||
per table, and core record-feature summaries include the presence snapshot.
|
||||
Browser events use SSE; HTTP performs lease mutations; Redis Pub/Sub carries scoped invalidation hints. Route each stream's subscription requests to its owning node. The Redis admin dashboard and framework health checks expose the configured connection and subscription state. Presence is advisory; supported versioned database writes protect concurrent saves.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Coordinated collaboration snapshot
|
||||
|
||||
The main-line candidate is published. Use these versions together:
|
||||
|
||||
| Component | Version |
|
||||
| --- | --- |
|
||||
| QQQ BOM and modules | `4.1.0-collab-33464d37e-SNAPSHOT` |
|
||||
| qbit-redis | `0.1.0-redis-7fe5492-SNAPSHOT` |
|
||||
| qbit-collaboration | `0.1.0-collab-8f348da-SNAPSHOT` |
|
||||
| qbit-session-store | `0.1.0-session-b5276ab-SNAPSHOT` |
|
||||
| Next frontend source | `8d372142f8af0c41a75a353edc6d150c33ccf613` |
|
||||
|
||||
Maven snapshot repository: https://central.sonatype.com/repository/maven-snapshots/ . Import the candidate QQQ BOM in the host to align transitive QQQ modules. The standalone consumer was verified from an empty cache. This is a feature snapshot, not a stable/GA release or a production deployment.
|
||||
|
||||
Frontend build and full browser acceptance: https://github.com/QRun-IO/qqq-frontend-next/actions/runs/39683690943 . Its acceptance-build artifact contains the tested export. No dashboard container image publication is implied. Install the export through the normal host frontend packaging path; upgrade frontend and backend together because the shared-presence issued-lease protocol changed.
|
||||
|
||||
## Host requirements
|
||||
|
||||
- Configure a named Redis connection through qbit-redis and enable CollaborationQBitConfig.redisPresence. Use explicit deployment/application namespaces and a PresenceScopeProvider derived from authoritative authenticated tenant context.
|
||||
- Start Redis infrastructure before dependent services and stop dependent services before closing its pools. Session-store may select SHARED_REDIS to reuse the named connection; its session semantics remain owned by session-store.
|
||||
- Configure TLS/authentication and bounded timeouts/pools for the actual environment. The live acceptance fixture's plaintext loopback Redis and mock authentication are test-only.
|
||||
- Deliver SSE without proxy buffering. The stream and its subscription updates must reach the owning web node; configure affinity for that connection. Shared Redis does not make node-local SSE connection IDs portable.
|
||||
- Configure record versions on supported backend paths for stale-save protection. Presence is advisory and never replaces database compare-and-write. Unversioned records do not gain conflict protection merely by enabling presence.
|
||||
- Use explicit QInstance registration when startup code runs outside QContext. Follow core docs/misc/SessionStoreIsolation.md and session-store docs/SESSION-REGISTRATION.md. JDBC shutdown and health operations must target the owning application; see core docs/lifecycle/application-shutdown.md.
|
||||
- Apply generated schema/migrations through the host's migration tooling. Do not infer that publishing this library migrated an existing database or an external deployed host.
|
||||
|
||||
## Verification
|
||||
|
||||
All four standard backend publishers and the three QBit source CI runs passed. Tests against published prerequisites: Redis23, collaboration177, session-store96; zero failures/errors/skips and configured coverage gates passed. Core verification4209 and frontend acceptance39683690943 passed. Frontend local3664 tests and TypeScript passed.
|
||||
|
||||
Independent JAR/POM downloads, exact timestamped versions, SHA-256 hashes and an empty-cache standalone consumer passed. Final browser checks used published backend JARs, the exact CI frontend export, two independent JVMs and one Redis endpoint. Cross-node avatars, self exclusion, concurrent editor field focus, preserved drafts, table presence and departure passed. Redis admin pool/subscription metrics, persisted framework UP health history and health-record navigation passed.
|
||||
|
||||
The two-node fixture uses independent memory records and mock users; it proves published-artifact presence/admin integration. Database conflict and authentication behavior have separate core/frontend acceptance evidence. Publication does not prove unknown deployed hosts have migrated.
|
||||
|
||||
Full evidence and issue reconciliation: https://github.com/QRun-IO/qqq/issues/1293#issuecomment-6329618640 .
|
||||
|
||||
## Supported milestone and optional work
|
||||
|
||||
The owner requested the main-line flow on N web nodes sharing one configured Redis endpoint. Browser delivery remains SSE plus HTTP lease writes; Redis Pub/Sub supplies invalidation hints. A new WebSocket protocol is not required.
|
||||
|
||||
Data-loss/restore qualification (#1300), additional failover topologies (#1301), sustained scale/failure injection (#1302), Windows tooling qualifications (#1304/#1305/#1309), CI mirror resilience (#1307), search layout polish (#1310), long-version preflight (#1312) and test-only AssertJ maintenance (#1314) remain tracked separately in the QQQ project. No measured production capacity or disaster-recovery guarantee is claimed by this snapshot.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Shared Redis presence candidate
|
||||
|
||||
Status: coordinated candidate artifacts are published; remote CI, empty-cache consumer resolution and published-artifact two-node browser checks passed. See [CANDIDATE-RELEASE.md](CANDIDATE-RELEASE.md) for exact versions and deployment requirements. This guide describes the current main-line scope. See MAINLINE-ACCEPTANCE.md for evidence and SESSION-STATE.md for live release checkpoints.
|
||||
|
||||
## Host configuration and dependency ownership
|
||||
|
||||
Multiple QQQ web nodes share one configured Redis endpoint. The host enables CollaborationQBitConfig.redisPresence, supplies a started named Redis connection, explicit deployment/application names, and a PresenceScopeProvider deriving one tenant scope from authenticated host context. Browser input cannot choose the tenant.
|
||||
|
||||
Collaboration owns presence semantics, Lua scripts, record/table projections and authorization. qbit-redis owns connections, namespaces, script execution, pool lifecycle, admin management and framework health integration. Collaboration production code consumes only the Redis public API and does not import Jedis. Without shared configuration, the local presence backend remains available for a single node; its issued authority is not distributed. Runtime Redis failure never silently switches an HA host to local presence.
|
||||
|
||||
Browser delivery uses the existing SSE stream with HTTP lease writes. Redis Pub/Sub carries scoped invalidation hints between nodes; a new WebSocket transport is not required. Hints contain table/record identifiers, not record contents or viewer lists. The recipient rereads through the authorized presence API. Core checks the authenticated recipient audience during live delivery and replay. Coordinate core/frontend/QBit versions and do not share a topic with audience-unaware hubs.
|
||||
|
||||
## Issued lease lifecycle
|
||||
|
||||
The candidate collaboration extension version is 1.3.0:
|
||||
|
||||
- POST /presence/leases authorizes the table/record and creates an invisible, expiring reservation, returning a server-issued connectionId.
|
||||
- POST /presence/leases/renew requires that issued handle and a positive safe-integer sequence. It activates or updates existing live authority; it cannot recreate an expired or departed lease.
|
||||
- DELETE /presence performs ordered departure. Delayed renewals cannot reopen the departed issued handle.
|
||||
- HTTP 409 with code PRESENCE_LEASE_EXPIRED tells an active browser to open a fresh lease. Input, permission and infrastructure failures retain their standard mappings. Unavailable shared storage returns 503 rather than an empty-room result.
|
||||
- Shared presence rejects legacy heartbeat upserts. Local legacy behavior remains for compatibility.
|
||||
|
||||
The Next candidate awaits open before activation, uses the handle across focus changes, and releases it on hide/unmount. It opens again on pageshow or explicit expiry. Generation checks discard old asynchronous responses; a late open result is released without activation. One immediate rejoin is allowed after expiry, then retries follow the normal 20-second heartbeat. Infrastructure errors do not churn handles. The UI preserves drafts and shows unavailable status when presence cannot be read or renewed.
|
||||
|
||||
## State and permissions
|
||||
|
||||
Record and table rooms use bounded expiry indexes and state hashes in the same Redis cluster hash slot for their deployment/application/tenant/feature namespace. Lua updates both projections atomically using Redis TIME. User/connection/record identifiers are encoded independently. No record values are stored.
|
||||
|
||||
Snapshots merge tabs by user. EDITING takes precedence over VIEWING; focus follows the latest renewal within the selected mode. Table projections omit focused fields. Expired entries are pruned on reads/mutations and idle keys expire. Invisible reservations count toward capacity. Scripts use EVALSHA with fallback only for NOSCRIPT, through the Redis QBit API.
|
||||
|
||||
Reads and renewals recheck parent and field access. Canonical record identifiers and recipient field filtering are enforced. Admin counts are limited to known rooms in the administrator's authenticated tenant; non-admins receive no counts. Capacity and TTL limits bound individual operations; they are not measured sustained production throughput guarantees.
|
||||
|
||||
## Verified behavior
|
||||
|
||||
- Real Redis tests cover six users on a record with two editors, duplicate-tab aggregation, ordering, departure, expiry, isolation, capacity and script-cache recovery.
|
||||
- Service and real HTTP tests cover issued open/activation/renew/departure, 409 expiry, authorization, canonical keys, field projection and unavailable responses.
|
||||
- Clean release-source collaboration verify passed 177 tests with coverage/architecture gates; focused frontend suites passed 34 tests, TypeScript and changed-file lint.
|
||||
- Live browser acceptance with mocks disabled used three identities on two independent QQQ JVMs sharing Redis. It verified avatars, field focus, drafts, table projection and departure. Demo records were independently seeded memory data; this is presence evidence, not cross-node database save-consistency evidence.
|
||||
- Live Redis admin acceptance verified pool/subscription status, persisted framework health results and navigation to standard health records.
|
||||
|
||||
Presence is advisory. Atomic optimistic record-version checks protect saves independently. Coordinated cold-consumer acceptance against published artifacts remains required; local Maven cache success does not prove remote artifact availability.
|
||||
|
||||
## Deferred work
|
||||
|
||||
The user narrowed this release to the normal flow. Optional qualification is tracked in QQQ issues #1300 (data loss/backup restore), #1301 (failover/disaster topologies), and #1302 (sustained scale/failure injection), all on the QQQ Roadmap backlog.
|
||||
|
||||
Issued leases do not provide disaster-recovery fencing: complete state loss can temporarily look empty, and restoring an old live lease can restore its authority. No recovery generation guard is implemented in this candidate. The deferred investigation is tracked in [QQQ #1300](https://github.com/QRun-IO/qqq/issues/1300).
|
||||
@@ -18,6 +18,9 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<!-- Only the published Redis API package is a permitted cross-qbit boundary. -->
|
||||
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email.api,com.kingsrook.qbits.redis.api</qbit.guardrails.allowedApiPackages>
|
||||
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:qbit-email,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-redis</qbit.guardrails.allowedQbitArtifacts>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -69,6 +72,18 @@
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-redis</artifactId>
|
||||
<version>0.1.0-redis-7fe5492-SNAPSHOT</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
@@ -113,7 +128,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>
|
||||
@@ -196,14 +240,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. -->
|
||||
<!-- Candidate requires core changes in the coordinated 4.1.0 SNAPSHOT. -->
|
||||
<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-collab-33464d37e-SNAPSHOT</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -47,6 +47,7 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
private List<String> mirroredSecurityKeyTypes = List.of();
|
||||
|
||||
private QCodeReference notifier = null;
|
||||
private com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig redisPresence;
|
||||
private Function<String, String> recipientEmailResolver = null;
|
||||
private QCodeReference userSessionProvider = null;
|
||||
private String defaultTagMode = "FREE";
|
||||
@@ -297,6 +298,10 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
@Override
|
||||
public void validate(QInstance instance, List<String> errors)
|
||||
{
|
||||
if(redisPresence != null)
|
||||
{
|
||||
redisPresence.validate(errors);
|
||||
}
|
||||
if(assertCondition(StringUtils.hasContent(backendName), "backendName is required for Collaboration", errors))
|
||||
{
|
||||
assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors);
|
||||
@@ -674,4 +679,21 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
{
|
||||
this.managedIndexes = managedIndexes;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Optional shared Redis presence; absent retains the existing memory provider.
|
||||
***************************************************************************/
|
||||
public com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig getRedisPresence()
|
||||
{
|
||||
return (redisPresence);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Select shared presence with explicit host-derived tenant scope.
|
||||
***************************************************************************/
|
||||
public CollaborationQBitConfig withRedisPresence(com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig value)
|
||||
{
|
||||
redisPresence = value;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
@@ -123,6 +155,10 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
public MetaDataProducerMultiOutput produce(QInstance instance) throws QException
|
||||
{
|
||||
qBitConfig.validate(instance);
|
||||
var realtime = com.kingsrook.qqq.backend.core.realtime.RealtimeMetaData.of(instance);
|
||||
realtime.withAudienceProvider(com.kingsrook.qbits.collaboration.presence.RedisPresenceAudience.EVENT,
|
||||
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.collaboration.presence.RedisPresenceAudience.class));
|
||||
instance.withSupplementalMetaData(realtime);
|
||||
QBitMetaData qBit = getQBitMetaData();
|
||||
instance.addQBit(qBit);
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
@@ -151,6 +187,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);
|
||||
@@ -178,6 +215,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());
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public class CollaborationSummaryProvider implements RecordFeatureSummaryProvide
|
||||
String user = PersonalRecords.currentUserId();
|
||||
if(features.isEnabled(RecordFeature.PRESENCE))
|
||||
{
|
||||
summary.setPresence(com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).snapshot(ref.tableName(), id).stream().map(viewer ->
|
||||
summary.setPresence(new com.kingsrook.qbits.collaboration.presence.PresenceService().snapshot(ref.tableName(), id).stream().map(viewer ->
|
||||
{
|
||||
java.util.Map<String, java.io.Serializable> entry = new java.util.LinkedHashMap<>();
|
||||
entry.put("userId", viewer.userId());
|
||||
|
||||
@@ -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,9 +52,10 @@ public final class CollaborationApi
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0")
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.3.0")
|
||||
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence")
|
||||
.withSpec(PostPresenceSpec.class).withSpec(DeletePresenceSpec.class).withSpec(GetPresenceSpec.class)
|
||||
.withSpec(OpenPresenceLeaseSpec.class).withSpec(RenewPresenceLeaseSpec.class)
|
||||
.withSpec(ListFavoritesSpec.class)
|
||||
.withSpec(StarFavoriteSpec.class)
|
||||
.withSpec(UnstarFavoriteSpec.class)
|
||||
@@ -96,6 +97,7 @@ public final class CollaborationApi
|
||||
.withSpec(ListMyTasksSpec.class)
|
||||
.withSpec(PutAssignmentSpec.class)
|
||||
.withSpec(GetMyWorkSpec.class)
|
||||
.withSpec(MarkMentionsReadSpec.class)
|
||||
.withSpec(UploadAttachmentSpec.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,11 @@ 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()),
|
||||
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Service temporarily unavailable", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+78
-4
@@ -41,11 +41,67 @@ public final class CollaborationPresenceSchemas
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Target for invisible server-issued renewal authority.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceOpenV1(String table, String pk) implements ToSchema
|
||||
{
|
||||
/***************************************************************************
|
||||
** Opening alone never advertises a viewer.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.of(
|
||||
"table", text("Parent table name.", false), "pk", text("Optional record key.", true)))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Opaque handle bound to the authenticated caller and target.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceLeaseV1(String connectionId) implements ToSchema
|
||||
{
|
||||
/***************************************************************************
|
||||
** Activate with a positive sequence; acquire a new handle after expiry.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(Map.of(
|
||||
"connectionId", text("Server-issued handle. Renew before 45 seconds; departure is terminal.", false))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable machine-readable response for missing renewal authority.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceExpiredV1(String code) implements ToSchema
|
||||
{
|
||||
/***************************************************************************
|
||||
** Clients rejoin only if their surface is still active and visible.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(Map.of(
|
||||
"code", text("Open a fresh lease before retrying renewal.", false).withEnumValues(List.of("PRESENCE_LEASE_EXPIRED")))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All identity information is derived from the authenticated session.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceRequestV1(String connectionId, String table, String pk, String mode, String fieldName) implements ToSchema
|
||||
public record CollaborationPresenceRequestV1(String connectionId, String table, String pk, String mode, String fieldName, Long sequence) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve source callers that do not yet send sequence numbers.
|
||||
*******************************************************************************/
|
||||
public CollaborationPresenceRequestV1(String connectionId, String table, String pk, String mode, String fieldName)
|
||||
{
|
||||
this(connectionId, table, pk, mode, fieldName, null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clients renew every twenty seconds; the lease expires after forty-five.
|
||||
*******************************************************************************/
|
||||
@@ -57,15 +113,24 @@ public final class CollaborationPresenceSchemas
|
||||
"connectionId", text("Client connection identity.", false), "table", text("Parent table name.", false),
|
||||
"pk", text("Optional parent key; omit for a table view.", true),
|
||||
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")),
|
||||
"fieldName", text("Optional focused field on a record.", true)))));
|
||||
"fieldName", text("Optional focused field on a record.", true),
|
||||
"sequence", sequenceSchema()))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave removes only this authenticated user's connection and target.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceLeaveV1(String connectionId, String table, String pk) implements ToSchema
|
||||
public record CollaborationPresenceLeaveV1(String connectionId, String table, String pk, Long sequence) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve source callers that do not yet send sequence numbers.
|
||||
*******************************************************************************/
|
||||
public CollaborationPresenceLeaveV1(String connectionId, String table, String pk)
|
||||
{
|
||||
this(connectionId, table, pk, null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave does not need a mode or a field.
|
||||
*******************************************************************************/
|
||||
@@ -75,7 +140,7 @@ public final class CollaborationPresenceSchemas
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Remove the caller's lease on this connection and target.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"connectionId", text("Client connection identity.", false), "table", text("Parent table name.", false),
|
||||
"pk", text("Optional parent key.", true)))));
|
||||
"pk", text("Optional parent key.", true), "sequence", sequenceSchema()))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,4 +187,13 @@ public final class CollaborationPresenceSchemas
|
||||
{
|
||||
return (new Schema().withType(Type.STRING).withDescription(description).withNullable(nullable));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional for legacy clients; new clients increment on every renewal and leave.
|
||||
*******************************************************************************/
|
||||
private static Schema sequenceSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.INTEGER).withNullable(true)
|
||||
.withDescription("Monotonically increasing nonnegative safe integer per connection and target; orders delayed requests."));
|
||||
}
|
||||
}
|
||||
@@ -514,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))))));
|
||||
}
|
||||
}
|
||||
@@ -714,6 +714,13 @@ 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.put("CollaborationPresenceOpenV1", new CollaborationPresenceSchemas.CollaborationPresenceOpenV1(null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceLeaseV1", new CollaborationPresenceSchemas.CollaborationPresenceLeaseV1(null).toSchema());
|
||||
schemas.put("CollaborationPresenceExpiredV1", new CollaborationPresenceSchemas.CollaborationPresenceExpiredV1(null).toSchema());
|
||||
schemas.putAll(CollaborationWorkSchemas.components());
|
||||
return (schemas);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ public class DeletePresenceSpec extends CollaborationJsonSpec
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceLeaveV1.class);
|
||||
new PresenceService().leave(body.connectionId(), required(body.table(), "table"), body.pk());
|
||||
new PresenceService().leave(body.connectionId(), required(body.table(), "table"), body.pk(), body.sequence());
|
||||
return (success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark all currently visible unread mentions read.
|
||||
*******************************************************************************/
|
||||
public class MarkMentionsReadSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/my-work/mentions/read", "Mark my visible mentions read"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationMutationResultV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the service as the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.mywork.MyWorkService().markMentionsRead(null);
|
||||
return (success());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew presence with fresh parent access checks.
|
||||
*******************************************************************************/
|
||||
public class OpenPresenceLeaseSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the extension operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/presence/leases", "Open presence lease"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit query parameters for polling.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationPresenceLeaseV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare a typed JSON request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationPresenceOpenV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services derive identity from QContext and enforce visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceOpenV1.class);
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceLeaseV1(new PresenceService().openLease(required(body.table(), "table"), body.pk())));
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,6 @@ public class PostPresenceSpec extends CollaborationJsonSpec
|
||||
{
|
||||
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceRequestV1.class);
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(body.table(), body.pk(),
|
||||
new PresenceService().heartbeat(body.connectionId(), required(body.table(), "table"), body.pk(), body.mode(), body.fieldName())));
|
||||
new PresenceService().heartbeat(body.connectionId(), required(body.table(), "table"), body.pk(), body.mode(), body.fieldName(), body.sequence())));
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public class PutAssignmentSpec extends CollaborationJsonSpec
|
||||
{
|
||||
var body = body(input, CollaborationWorkSchemas.CollaborationAssignmentRequestV1.class);
|
||||
return (CollaborationViews.assignment(new com.kingsrook.qbits.collaboration.assignment.AssignmentService()
|
||||
.assign(table(input), pk(input), body.ownerUserId(), body.assigneeUserId())));
|
||||
.assignRoles(table(input), pk(input), body.ownerUserId(), body.assigneeUserId(), input.getBody().keySet())));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew presence with fresh parent access checks.
|
||||
*******************************************************************************/
|
||||
public class RenewPresenceLeaseSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Expiry is distinct from permissions, invalid input and service outages.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<com.kingsrook.qqq.middleware.javalin.specs.BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
var responses = new java.util.ArrayList<>(super.defineAdditionalBasicResponses());
|
||||
responses.add(new com.kingsrook.qqq.middleware.javalin.specs.BasicResponse(io.javalin.http.HttpStatus.CONFLICT,
|
||||
"Presence lease expired; open a new lease.", "CollaborationPresenceExpiredV1"));
|
||||
return (responses);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve standard framework mapping for every other exception.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void handleException(io.javalin.http.Context context, Exception exception)
|
||||
{
|
||||
if(com.kingsrook.qqq.backend.core.utils.ExceptionUtils.findClassInRootChain(exception,
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class) != null)
|
||||
{
|
||||
context.status(409).contentType("application/json").result(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(
|
||||
new CollaborationPresenceSchemas.CollaborationPresenceExpiredV1("PRESENCE_LEASE_EXPIRED")));
|
||||
}
|
||||
else
|
||||
{
|
||||
super.handleException(context, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the extension operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/presence/leases/renew", "Renew issued presence lease"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit query parameters for polling.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationPresenceSnapshotV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare a typed JSON request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationPresenceRequestV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services derive identity from QContext and enforce visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceRequestV1.class);
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(body.table(), body.pk(),
|
||||
new PresenceService().renewLease(body.connectionId(), required(body.table(), "table"), body.pk(), body.mode(), body.fieldName(), body.sequence())));
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
*******************************************************************************/
|
||||
public final class 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(KPI_WIDGET, TAGS_WIDGET, FIELDS_WIDGET, PRESENCE_WIDGET);
|
||||
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.";
|
||||
@@ -120,6 +121,7 @@ public final class CollaborationHome
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
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),
|
||||
@@ -140,6 +142,30 @@ public final class CollaborationHome
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each record feature, whether it is on, for which tables,
|
||||
** where that comes from, and the next step.
|
||||
*******************************************************************************/
|
||||
public static TableData setup() throws QException
|
||||
{
|
||||
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(CollaborationSetupStatus.Item item : CollaborationSetupStatus.load(QContext.getQInstance()))
|
||||
{
|
||||
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);
|
||||
}
|
||||
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")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The KPI groups: conversations, work, files and custom fields.
|
||||
*******************************************************************************/
|
||||
@@ -200,7 +226,7 @@ public final class CollaborationHome
|
||||
row.put("records", tag.getValueInteger("usageCount") == null ? 0 : tag.getValueInteger("usageCount"));
|
||||
rows.add(row);
|
||||
}
|
||||
return (table("Tags", NO_TAGS, rows, column("Label", "label", "3fr", "left"), column("Table", "table", "2fr", "left"), column("Records", "records", "1fr", "right")));
|
||||
return (table("Tags", NO_TAGS, rows, column("Label", "label", "left"), column("Table", "table", "left"), column("Records", "records", "right")));
|
||||
}
|
||||
|
||||
|
||||
@@ -224,8 +250,8 @@ public final class CollaborationHome
|
||||
row.put("indexState", friendly(definition.getValueString("indexState")));
|
||||
rows.add(row);
|
||||
}
|
||||
return (table("Custom fields", NO_FIELDS, rows, column("Name", "name", "3fr", "left"), column("Table", "table", "2fr", "left"),
|
||||
column("Status", "status", "1fr", "left"), column("Index state", "indexState", "1fr", "left")));
|
||||
return (table("Custom fields", NO_FIELDS, rows, column("Name", "name", "left"), column("Table", "table", "left"),
|
||||
column("Status", "status", "left"), column("Index state", "indexState", "left")));
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +267,29 @@ public final class CollaborationHome
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if(admin)
|
||||
{
|
||||
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
|
||||
var instance = QContext.getQInstance();
|
||||
var redis = com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig.forInstance(instance);
|
||||
Map<String, Integer> counts;
|
||||
if(redis == null)
|
||||
{
|
||||
counts = PresenceRegistry.get(instance).counts();
|
||||
}
|
||||
else
|
||||
{
|
||||
counts = new java.util.TreeMap<>();
|
||||
var backend = new com.kingsrook.qbits.collaboration.presence.RedisPresenceBackend(redis);
|
||||
// Read only known table rooms in the administrator's authenticated tenant.
|
||||
// Never scan Redis keys or silently substitute local counts on failure.
|
||||
for(String tableName : instance.getTables().keySet())
|
||||
{
|
||||
int viewers = backend.snapshot(tableName, null).size();
|
||||
if(viewers > 0)
|
||||
{
|
||||
counts.put(tableName, viewers);
|
||||
}
|
||||
}
|
||||
}
|
||||
counts.forEach((table, viewers) ->
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("table", tableLabel(table));
|
||||
@@ -249,7 +297,7 @@ public final class CollaborationHome
|
||||
rows.add(row);
|
||||
});
|
||||
}
|
||||
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "3fr", "left"), column("People", "viewers", "1fr", "right")));
|
||||
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right")));
|
||||
}
|
||||
|
||||
|
||||
@@ -267,9 +315,12 @@ public final class CollaborationHome
|
||||
/*******************************************************************************
|
||||
** A plain column; the frontend styles it.
|
||||
*******************************************************************************/
|
||||
private static TableData.Column column(String header, String accessor, String width, String align)
|
||||
private static TableData.Column column(String header, String accessor, String align)
|
||||
{
|
||||
return (new TableData.Column("default", header, accessor, width, align));
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// no width: frontends size the columns to fit narrow (390 px) cards //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
return (new TableData.Column("default", header, accessor, null, align));
|
||||
}
|
||||
|
||||
|
||||
@@ -360,6 +411,23 @@ public final class CollaborationHome
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
public static class SetupRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render using the current session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(setup()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** KPI renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,15 +53,42 @@ public class AssignmentService
|
||||
** Full replacement of both roles; null clears a role.
|
||||
*******************************************************************************/
|
||||
public QRecord assign(String table, Serializable recordId, String ownerUserId, String assigneeUserId) throws QException
|
||||
{
|
||||
return (assignRoles(table, recordId, ownerUserId, assigneeUserId, java.util.Set.of("ownerUserId", "assigneeUserId")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Omitted roles remain unchanged; an explicitly supplied null clears a role.
|
||||
*******************************************************************************/
|
||||
public QRecord assignRoles(String table, Serializable recordId, String ownerUserId, String assigneeUserId, java.util.Set<String> roles) throws QException
|
||||
{
|
||||
ParentRecordAccess.requireFeature(table, recordId, "assign");
|
||||
String id = FollowService.canonicalId(table, recordId);
|
||||
if(roles == null || roles.isEmpty() || !java.util.Set.of("ownerUserId", "assigneeUserId").containsAll(roles))
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Supply ownerUserId or assigneeUserId; omitted roles remain unchanged."));
|
||||
}
|
||||
var instance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
|
||||
var ownership = com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver.resolve(instance, instance.getTable(table)).getOwnership();
|
||||
if(ownership != null && ((roles.contains("ownerUserId") && ownership.getOwnerFieldName() != null)
|
||||
|| (roles.contains("assigneeUserId") && ownership.getAssigneeFieldName() != null)))
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Update native ownership fields through the parent record's versioned update endpoint."));
|
||||
}
|
||||
WorkSupport.requireReader(ownerUserId, table, id);
|
||||
WorkSupport.requireReader(assigneeUserId, table, id);
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
Optional<QRecord> old = get(table, id);
|
||||
QRecord change = new QRecord().withValue("ownerUserId", ownerUserId).withValue("assigneeUserId", assigneeUserId);
|
||||
QRecord change = new QRecord();
|
||||
if(roles.contains("ownerUserId"))
|
||||
{
|
||||
change.setValue("ownerUserId", ownerUserId);
|
||||
}
|
||||
if(roles.contains("assigneeUserId"))
|
||||
{
|
||||
change.setValue("assigneeUserId", assigneeUserId);
|
||||
}
|
||||
QRecord row;
|
||||
if(old.isEmpty())
|
||||
{
|
||||
@@ -73,7 +100,7 @@ public class AssignmentService
|
||||
row = get(table, id).orElseThrow();
|
||||
}
|
||||
ActivityWriter.write(table, id, "ASSIGNED", "Record assignment changed", CollaborationTables.RECORD_ASSIGNMENT, row.getValueString("id"));
|
||||
if(assigneeUserId != null && (old.isEmpty() || !Objects.equals(old.get().getValueString("assigneeUserId"), assigneeUserId)))
|
||||
if(roles.contains("assigneeUserId") && assigneeUserId != null && (old.isEmpty() || !Objects.equals(old.get().getValueString("assigneeUserId"), assigneeUserId)))
|
||||
{
|
||||
new FollowService().ensureFollower(assigneeUserId, table, id, "ASSIGNED");
|
||||
if(!assigneeUserId.equals(PersonalRecords.currentUserId()))
|
||||
|
||||
@@ -129,10 +129,20 @@ public class AttachmentsService
|
||||
}
|
||||
}
|
||||
String type = contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType;
|
||||
var transaction = QContext.getQBackendTransaction();
|
||||
if(transaction != null && !transaction.supportsRollbackCallbacks())
|
||||
{
|
||||
throw (new QException("Attachment uploads require a transaction with confirmed rollback support."));
|
||||
}
|
||||
try(InputStream upload = Files.newInputStream(temporary))
|
||||
{
|
||||
storage.put(bucket, PutRequest.of(key, upload).withContentLength(size).withContentType(type).withContentDisposition("inline"));
|
||||
}
|
||||
if(transaction != null)
|
||||
{
|
||||
var context = new com.kingsrook.qqq.backend.core.context.CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
transaction.addAfterRollbackCallback(() -> QContext.withTemporaryContext(context, () -> storage.delete(bucket, key)));
|
||||
}
|
||||
boolean rowSaved = false;
|
||||
try
|
||||
{
|
||||
@@ -303,6 +313,22 @@ public class AttachmentsService
|
||||
*******************************************************************************/
|
||||
void deleteStoredRow(QRecord row, boolean cleanup) throws QException
|
||||
{
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
var context = new com.kingsrook.qqq.backend.core.context.CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
QContext.getQBackendTransaction().addAfterCommitCallback(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> deleteStoredRow(row, cleanup));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Attachment deletion requires retry", e));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
storage.delete(row.getValueString("bucketName"), row.getValueString("storageKey"));
|
||||
DeleteInput input = new DeleteInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withPrimaryKeys(List.of(row.getValue("id")));
|
||||
input.setTableMetaData(cleanup ? com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT) : AttachmentRecords.storageTable());
|
||||
|
||||
+42
-6
@@ -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,7 +317,8 @@ 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,
|
||||
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,
|
||||
|
||||
+4
-1
@@ -58,7 +58,10 @@ public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
|
||||
@Override
|
||||
public RuntimeMetaDataContribution getContribution(QInstance instance) throws QException
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(instance);
|
||||
if(com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig.forInstance(instance) == null)
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(instance);
|
||||
}
|
||||
var result = new RuntimeMetaDataContribution();
|
||||
var overrides = new FeatureSettingsProvider().getOverrides(instance);
|
||||
List<QRecord> definitions = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION,
|
||||
|
||||
+15
-2
@@ -89,11 +89,20 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(CollaborationNotification n)
|
||||
{
|
||||
deliver(n);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A receipt means the queue accepted this event, including an existing duplicate.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean deliver(CollaborationNotification n)
|
||||
{
|
||||
EmailApiTemplate template = templateFor(n.category());
|
||||
if(template == null)
|
||||
{
|
||||
return;
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
@@ -101,7 +110,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
if(!StringUtils.hasContent(address))
|
||||
{
|
||||
LOG.info("Collaboration email skipped: no address for recipient", logPair("userId", n.recipientUserId()));
|
||||
return;
|
||||
return (false);
|
||||
}
|
||||
EmailApi.ensureTemplate(template);
|
||||
String eventId = n.sourceId() != null ? n.sourceId() : n.sourceRecordId();
|
||||
@@ -123,10 +132,12 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
LOG.info("Collaboration email not queued", logPair("userId", n.recipientUserId()), logPair("status", result.status()),
|
||||
logPair("messageId", result.messageId()));
|
||||
}
|
||||
return (result.status() == EmailApiStatus.QUEUED || result.status() == EmailApiStatus.DUPLICATE);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("Collaboration email failed", e, logPair("userId", n.recipientUserId()), logPair("category", n.category()));
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +194,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
{
|
||||
case MENTION -> "mention";
|
||||
case SHARE -> "share";
|
||||
case FOLLOW -> "follow";
|
||||
default -> "task";
|
||||
});
|
||||
}
|
||||
@@ -199,6 +211,7 @@ public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
case MENTION -> mentionTemplate();
|
||||
case SHARE -> shareTemplate();
|
||||
case TASK -> taskReminderTemplate();
|
||||
case FOLLOW -> definition("collaboration_follow", "Followed record changed", "Changes to {{recordLabel}}", "A record you follow has changed.\n\n[View record]({{link}})");
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -193,12 +193,17 @@ public class FollowChangeBatcher implements RecordChangeListenerInterface
|
||||
String user = batch.getValueString("userId");
|
||||
List<QRecord> follows = CollaborationRecords.query(CollaborationTables.FOLLOW,
|
||||
CollaborationRecords.parent(table, id).withCriteria("userId", QCriteriaOperator.EQUALS, user));
|
||||
if(!follows.isEmpty() && eligible(follows.getFirst(), now))
|
||||
if(!follows.isEmpty() && eligible(follows.getFirst(), now)
|
||||
&& new com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker().canRead(user, table, id).orElse(false))
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(user, "Changes to a followed record", batch.getValueString("changes"),
|
||||
CollaborationNotification.Category.FOLLOW, table, id, Notifications.link(table, id)));
|
||||
CollaborationNotification.Category.FOLLOW, table, id, Notifications.link(table, id)).withEvent(batch.getValue("id"), null),
|
||||
() -> CollaborationRecords.delete(CollaborationTables.FOLLOW_DIGEST, batch.getValue("id")));
|
||||
}
|
||||
else
|
||||
{
|
||||
CollaborationRecords.delete(CollaborationTables.FOLLOW_DIGEST, batch.getValue("id"));
|
||||
}
|
||||
CollaborationRecords.delete(CollaborationTables.FOLLOW_DIGEST, batch.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,4 +31,13 @@ public interface CollaborationNotifier
|
||||
** Deliver a recipient-authorized notification.
|
||||
*******************************************************************************/
|
||||
void notify(CollaborationNotification n);
|
||||
|
||||
/*******************************************************************************
|
||||
** Legacy transports signal failure by throwing; adapters may explicitly refuse.
|
||||
*******************************************************************************/
|
||||
default boolean deliver(CollaborationNotification notification)
|
||||
{
|
||||
notify(notification);
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,13 @@ public class NoOpNotifier implements CollaborationNotifier
|
||||
public void notify(CollaborationNotification n)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No transport cannot acknowledge delivery.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean deliver(CollaborationNotification notification)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,10 @@ public final class Notifications
|
||||
if(!checkParent || new CollaborationRecordAccessChecker().canRead(notification.recipientUserId(), notification.sourceTableName(), notification.sourceRecordId()).orElse(false))
|
||||
{
|
||||
CollaborationNotifier notifier = notifier();
|
||||
notifier.notify(notification);
|
||||
if(!notifier.deliver(notification))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!(notifier instanceof NoOpNotifier))
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Ephemeral storage boundary behind the permission-checking presence service.
|
||||
*******************************************************************************/
|
||||
public interface PresenceBackend
|
||||
{
|
||||
/***************************************************************************
|
||||
** Reserve invisible renewal authority; null means capacity refusal.
|
||||
***************************************************************************/
|
||||
String openLease(String user, String name, String table, String pk) throws QException;
|
||||
|
||||
/***************************************************************************
|
||||
** Renew existing authority or throw PresenceLeaseExpiredException.
|
||||
***************************************************************************/
|
||||
boolean renewLease(String user, String name, String connection, String table, String pk, String mode, String field, Long sequence) throws QException;
|
||||
|
||||
/***************************************************************************
|
||||
** Renew a connection's ordered lease.
|
||||
***************************************************************************/
|
||||
boolean heartbeat(String user, String name, String connection, String table, String pk, String mode, String field, Long sequence) throws QException;
|
||||
|
||||
/***************************************************************************
|
||||
** Release only the authenticated user's connection.
|
||||
***************************************************************************/
|
||||
void leave(String user, String connection, String table, String pk, Long sequence) throws QException;
|
||||
|
||||
/***************************************************************************
|
||||
** Read a snapshot for subsequent recipient-specific permission filtering.
|
||||
***************************************************************************/
|
||||
List<PresenceRegistry.Viewer> snapshot(String table, String pk) throws QException;
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
/*******************************************************************************
|
||||
** Expired or absent renewal authority requires opening a fresh presence lease.
|
||||
*******************************************************************************/
|
||||
public final class PresenceLeaseExpiredException extends IllegalStateException
|
||||
{
|
||||
/***************************************************************************
|
||||
** Never include caller identifiers or stored data in this message.
|
||||
***************************************************************************/
|
||||
public PresenceLeaseExpiredException()
|
||||
{
|
||||
super("Presence lease expired; open a new lease");
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
|
||||
** Ephemeral per-pod presence replicated over a channel core refuses to clients.
|
||||
** Publish outside the monitor: the in-memory bus waits for listener threads.
|
||||
*******************************************************************************/
|
||||
public final class PresenceRegistry implements AutoCloseable
|
||||
public final class PresenceRegistry implements PresenceBackend, AutoCloseable
|
||||
{
|
||||
public static final String INTERNAL_CHANNEL = "instance:presence";
|
||||
public static final long TTL_MILLIS = 45000;
|
||||
@@ -119,7 +119,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
/*******************************************************************************
|
||||
** Each tab has its own lease; user views merge the live leases.
|
||||
*******************************************************************************/
|
||||
private record Entry(String displayName, String mode, String fieldName, long at)
|
||||
private record Entry(String displayName, String mode, String fieldName, long at, Long sequence)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -135,9 +135,21 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
*******************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName)
|
||||
{
|
||||
return (heartbeat(user, displayName, connection, table, pk, mode, fieldName, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Client sequence orders delayed HTTP requests independently of arrival time.
|
||||
*******************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName, Long sequence)
|
||||
{
|
||||
if(connection != null && connection.startsWith("lease:"))
|
||||
{
|
||||
throw (new PresenceLeaseExpiredException());
|
||||
}
|
||||
expire();
|
||||
var key = new Key(user, connection, table, pk);
|
||||
var entry = new Entry(displayName, mode, fieldName, clock.millis());
|
||||
var entry = new Entry(displayName, mode, fieldName, clock.millis(), sequence);
|
||||
if(!apply(key, entry, false))
|
||||
{
|
||||
return (false);
|
||||
@@ -146,13 +158,47 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
return (true);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Local mode issues local authority; HA applications must use shared Redis.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String openLease(String user, String displayName, String table, String pk)
|
||||
{
|
||||
expire();
|
||||
String connection = "lease:" + java.util.UUID.randomUUID();
|
||||
return (apply(new Key(user, connection, table, pk), new Entry(displayName, "PENDING", null, clock.millis(), 0L), false) ? connection : null);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Check renewal and update under one monitor; no late request creates a lease.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public boolean renewLease(String user, String displayName, String connection, String table, String pk, String mode, String fieldName, Long sequence)
|
||||
{
|
||||
if(connection == null || !connection.startsWith("lease:") || sequence == null || sequence < 1 || sequence > 9007199254740991L
|
||||
|| !List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode))
|
||||
{
|
||||
throw (new IllegalArgumentException("A server-issued lease, positive safe sequence and valid mode are required"));
|
||||
}
|
||||
expire();
|
||||
return (apply(new Key(user, connection, table, pk), new Entry(displayName, mode, fieldName, clock.millis(), sequence), false, true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Leaves use the same internal channel so every pod removes the lease.
|
||||
*******************************************************************************/
|
||||
public void leave(String user, String connection, String table, String pk)
|
||||
{
|
||||
leave(user, connection, table, pk, null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Departure participates in the same ordered client lease.
|
||||
*******************************************************************************/
|
||||
public void leave(String user, String connection, String table, String pk, Long sequence)
|
||||
{
|
||||
var key = new Key(user, connection, table, pk);
|
||||
var entry = new Entry(null, null, null, clock.millis());
|
||||
var entry = new Entry(null, null, null, clock.millis(), sequence);
|
||||
apply(key, entry, true);
|
||||
publish(key, entry, true);
|
||||
}
|
||||
@@ -162,6 +208,10 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
*******************************************************************************/
|
||||
private void publish(Key key, Entry entry, boolean leave)
|
||||
{
|
||||
if(key.connection().startsWith("lease:"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Map<String, Serializable> data = new LinkedHashMap<>();
|
||||
data.put("userId", key.user());
|
||||
data.put("connectionId", key.connection());
|
||||
@@ -172,6 +222,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
data.put("fieldName", entry.fieldName());
|
||||
data.put("at", entry.at());
|
||||
data.put("leave", leave);
|
||||
data.put("sequence", entry.sequence());
|
||||
bus.publish(new RealtimeMessage().withChannel(INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(data));
|
||||
}
|
||||
|
||||
@@ -192,41 +243,60 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
}
|
||||
expire();
|
||||
long receivedAt = Math.min(at.longValue(), clock.millis());
|
||||
// Best-effort replication cannot grant or restore issued renewal authority.
|
||||
if(connection.startsWith("lease:"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(receivedAt + TTL_MILLIS <= clock.millis())
|
||||
{
|
||||
return;
|
||||
}
|
||||
apply(new Key(user, connection, table, (String) data.get("pk")),
|
||||
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
|
||||
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt,
|
||||
data.get("sequence") instanceof Number sequence ? sequence.longValue() : null), Boolean.TRUE.equals(data.get("leave")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unchanged renewals extend TTL without generating client event traffic.
|
||||
*******************************************************************************/
|
||||
private boolean apply(Key key, Entry entry, boolean leave)
|
||||
{
|
||||
return (apply(key, entry, leave, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Local issued authority is checked atomically with the mutation.
|
||||
*******************************************************************************/
|
||||
private boolean apply(Key key, Entry entry, boolean leave, boolean requireLive)
|
||||
{
|
||||
boolean changed;
|
||||
synchronized(this)
|
||||
{
|
||||
Entry old = entries.get(key);
|
||||
if(old != null && old.at() > entry.at())
|
||||
if(requireLive && (old == null || old.mode() == null || old.at() + TTL_MILLIS <= clock.millis()))
|
||||
{
|
||||
throw (new PresenceLeaseExpiredException());
|
||||
}
|
||||
if(old != null && old.sequence() != null
|
||||
&& (entry.sequence() == null || entry.sequence() < old.sequence()
|
||||
|| (entry.sequence().equals(old.sequence()) && (!leave || old.mode() == null))))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(!leave && old == null && entries.size() >= capacity)
|
||||
if(old != null && old.sequence() == null && entry.sequence() == null
|
||||
&& (old.at() > entry.at() || (old.at() == entry.at() && old.mode() == null && !leave)))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(old == null && entries.size() >= capacity)
|
||||
{
|
||||
LOG.warn("Presence capacity reached; refusing a new target");
|
||||
return (false);
|
||||
}
|
||||
List<Viewer> before = snapshotInternal(key.table(), key.pk());
|
||||
if(leave)
|
||||
{
|
||||
entries.remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
entries.put(key, entry);
|
||||
}
|
||||
// Keep a bounded departure marker until old messages can no longer be fresh.
|
||||
entries.put(key, entry);
|
||||
changed = !before.equals(snapshotInternal(key.table(), key.pk()));
|
||||
}
|
||||
if(changed)
|
||||
@@ -277,7 +347,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
Map<String, Entry> users = new java.util.TreeMap<>();
|
||||
for(var row : entries.entrySet())
|
||||
{
|
||||
if(row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis())
|
||||
if(row.getValue().mode() != null && !"PENDING".equals(row.getValue().mode()) && row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis())
|
||||
{
|
||||
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
|
||||
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
/*******************************************************************************
|
||||
** Host resolves tenant scope from authenticated context, never request parameters.
|
||||
*******************************************************************************/
|
||||
public interface PresenceScopeProvider
|
||||
{
|
||||
/***************************************************************************
|
||||
** Return a stable nonblank scope; single-tenant hosts explicitly return one.
|
||||
***************************************************************************/
|
||||
String tenantScope();
|
||||
}
|
||||
@@ -30,19 +30,62 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.realtime.PresenceFieldVisibility;
|
||||
|
||||
/*******************************************************************************
|
||||
** Authenticated, permission-checked boundary for presence; nothing is persisted.
|
||||
*******************************************************************************/
|
||||
public class PresenceService
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Reserve invisible authority only for an authenticated, visible target.
|
||||
*******************************************************************************/
|
||||
public String openLease(String table, String pk) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
String canonical = authorize(table, pk);
|
||||
String name = QContext.getQSession().getUser().getFullName();
|
||||
String lease = registry().openLease(user, name == null || name.isBlank() ? user : name, table, canonical);
|
||||
if(lease == null)
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QServiceUnavailableException("Presence capacity reached; retry later"));
|
||||
}
|
||||
return (lease);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Issued renewals share the same identity, parent and field access checks.
|
||||
*******************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> renewLease(String connection, String table, String pk, String mode, String field, Long sequence) throws QException
|
||||
{
|
||||
requireIssuedLease(connection, sequence);
|
||||
return (renew(connection, table, pk, mode, field, sequence, true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recheck visibility on every renewal and remove a refused caller's old lease.
|
||||
*******************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> heartbeat(String connection, String table, String pk, String mode, String field) throws QException
|
||||
{
|
||||
return (heartbeat(connection, table, pk, mode, field, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered clients supply a monotonically increasing sequence per connection.
|
||||
*******************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> heartbeat(String connection, String table, String pk, String mode, String field, Long sequence) throws QException
|
||||
{
|
||||
return (renew(connection, table, pk, mode, field, sequence, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep legacy and issued requests on one permission and projection boundary.
|
||||
*******************************************************************************/
|
||||
private List<PresenceRegistry.Viewer> renew(String connection, String table, String pk, String mode, String field, Long sequence, boolean issued) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
validateConnection(connection);
|
||||
validateSequence(sequence);
|
||||
pk = canonicalKey(table, pk);
|
||||
try
|
||||
{
|
||||
@@ -50,34 +93,63 @@ public class PresenceService
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
registry().leave(user, connection, table, pk);
|
||||
try
|
||||
{
|
||||
registry().leave(user, connection, table, pk, sequence);
|
||||
}
|
||||
catch(QException cleanupFailure)
|
||||
{
|
||||
// Preserve the access refusal; an unreachable store expires the old lease.
|
||||
}
|
||||
throw (e);
|
||||
}
|
||||
if(!List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode))
|
||||
{
|
||||
throw (new QBadRequestException("Mode must be VIEWING or EDITING"));
|
||||
}
|
||||
if(field != null && (pk == null || QContext.getQInstance().getTable(table).getFields().get(field) == null))
|
||||
if(field != null && (pk == null || !PresenceFieldVisibility.visibleFieldNames(table).contains(field)))
|
||||
{
|
||||
throw (new QBadRequestException("Unknown focused field"));
|
||||
}
|
||||
String name = QContext.getQSession().getUser().getFullName();
|
||||
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, table, pk, mode, field))
|
||||
if(!issued && RedisPresenceConfig.forInstance(QContext.getQInstance()) != null)
|
||||
{
|
||||
throw (new QBadRequestException("Presence capacity reached; retry later"));
|
||||
throw (new QBadRequestException("Shared presence requires a server-issued lease; use the presence/leases endpoints"));
|
||||
}
|
||||
return (registry().snapshot(table, pk));
|
||||
String name = QContext.getQSession().getUser().getFullName();
|
||||
String displayName = name == null || name.isBlank() ? user : name;
|
||||
var backend = registry();
|
||||
boolean accepted = issued ? backend.renewLease(user, displayName, connection, table, pk, mode, field, sequence)
|
||||
: backend.heartbeat(user, displayName, connection, table, pk, mode, field, sequence);
|
||||
if(!accepted)
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QServiceUnavailableException("Presence capacity reached; retry later"));
|
||||
}
|
||||
return (visibleSnapshot(table, pk));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Namespacing by user prevents one user from removing another user's connection.
|
||||
*******************************************************************************/
|
||||
public void leave(String connection, String table, String pk) throws QException
|
||||
{
|
||||
leave(connection, table, pk, null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered departure cannot be undone by an earlier renewal arriving late.
|
||||
*******************************************************************************/
|
||||
public void leave(String connection, String table, String pk, Long sequence) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
validateConnection(connection);
|
||||
String canonical = authorize(table, pk);
|
||||
registry().leave(user, connection, table, canonical);
|
||||
validateSequence(sequence);
|
||||
if(connection.startsWith("lease:"))
|
||||
{
|
||||
requireIssuedLease(connection, sequence);
|
||||
}
|
||||
// Removal is scoped to the caller; revoked readers must still release their lease.
|
||||
String canonical = canonicalKey(table, pk);
|
||||
registry().leave(user, connection, table, canonical, sequence);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -86,7 +158,17 @@ public class PresenceService
|
||||
public List<PresenceRegistry.Viewer> snapshot(String table, String pk) throws QException
|
||||
{
|
||||
PersonalRecords.currentUserId();
|
||||
return (registry().snapshot(table, authorize(table, pk)));
|
||||
return (visibleSnapshot(table, authorize(table, pk)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project a fresh recipient view without mutating shared registry entries.
|
||||
*******************************************************************************/
|
||||
private List<PresenceRegistry.Viewer> visibleSnapshot(String table, String pk) throws QException
|
||||
{
|
||||
var visible = PresenceFieldVisibility.visibleFieldNames(table);
|
||||
return (registry().snapshot(table, pk).stream().map(viewer -> new PresenceRegistry.Viewer(viewer.userId(), viewer.displayName(), viewer.mode(),
|
||||
viewer.fieldName() != null && visible.contains(viewer.fieldName()) ? viewer.fieldName() : null)).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -111,10 +193,6 @@ public class PresenceService
|
||||
*******************************************************************************/
|
||||
private String canonicalKey(String tableName, String pk) throws QNotFoundException
|
||||
{
|
||||
if(pk == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
var table = QContext.getQInstance().getTable(tableName);
|
||||
@@ -122,6 +200,10 @@ public class PresenceService
|
||||
{
|
||||
throw (new IllegalArgumentException("Unknown table"));
|
||||
}
|
||||
if(pk == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), pk).toString());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
@@ -142,10 +224,39 @@ public class PresenceService
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All endpoints on a pod share the eagerly installed instance registry.
|
||||
** Issued handles require ordered activation and departure requests.
|
||||
*******************************************************************************/
|
||||
private PresenceRegistry registry()
|
||||
private void requireIssuedLease(String connection, Long sequence) throws QBadRequestException
|
||||
{
|
||||
validateConnection(connection);
|
||||
validateSequence(sequence);
|
||||
if(!connection.startsWith("lease:") || sequence == null || sequence < 1)
|
||||
{
|
||||
throw (new QBadRequestException("A server-issued lease and positive sequence are required"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Restrict ordered leases to exact nonnegative JavaScript integers.
|
||||
*******************************************************************************/
|
||||
private void validateSequence(Long sequence) throws QBadRequestException
|
||||
{
|
||||
if(sequence != null && (sequence < 0 || sequence > 9007199254740991L))
|
||||
{
|
||||
throw (new QBadRequestException("Presence sequence must be a nonnegative safe integer"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Select the configured backend without creating an unused local registry.
|
||||
*******************************************************************************/
|
||||
private PresenceBackend registry() throws QException
|
||||
{
|
||||
var config = RedisPresenceConfig.forInstance(QContext.getQInstance());
|
||||
if(config != null)
|
||||
{
|
||||
return (new RedisPresenceBackend(config));
|
||||
}
|
||||
return (PresenceRegistry.get(QContext.getQInstance()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import com.kingsrook.qbits.redis.api.RedisNamespace;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeAudienceProvider;
|
||||
|
||||
/*******************************************************************************
|
||||
** Presence hints are visible only within the authenticated storage namespace.
|
||||
*******************************************************************************/
|
||||
public class RedisPresenceAudience implements RealtimeAudienceProvider
|
||||
{
|
||||
public static final String EVENT = "presence.invalidated";
|
||||
|
||||
/***************************************************************************
|
||||
** Recompute recipient scope on every delivery and replay; never trust the hint.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public boolean allows(String audience) throws Exception
|
||||
{
|
||||
var config = RedisPresenceConfig.forInstance(QContext.getQInstance());
|
||||
return (config != null && namespace(config).key("audience").equals(audience));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Storage and delivery must derive exactly the same host-owned tenant scope.
|
||||
***************************************************************************/
|
||||
public static RedisNamespace namespace(RedisPresenceConfig config) throws Exception
|
||||
{
|
||||
var scope = QCodeLoader.getAdHoc(PresenceScopeProvider.class, config.getScopeProvider()).tenantScope();
|
||||
return (new RedisNamespace(config.getDeployment(), config.getApplication(), scope, "presence"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.redis.api.RedisAccess;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QServiceUnavailableException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Request-scoped Redis adapter; tenant identity is re-derived for every request.
|
||||
*******************************************************************************/
|
||||
public class RedisPresenceBackend implements PresenceBackend
|
||||
{
|
||||
private final RedisPresenceStore store;
|
||||
private final String audience;
|
||||
|
||||
/***************************************************************************
|
||||
** Require a running named pool and an explicit host-derived tenant namespace.
|
||||
***************************************************************************/
|
||||
public RedisPresenceBackend(RedisPresenceConfig config) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
var namespace = RedisPresenceAudience.namespace(config);
|
||||
audience = namespace.key("audience");
|
||||
var client = RedisAccess.scripts(QContext.getQInstance(), config.getConnectionName());
|
||||
store = new RedisPresenceStore(client, namespace, Duration.ofMillis(PresenceRegistry.TTL_MILLIS), config.getCapacity());
|
||||
}
|
||||
catch(Exception failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Reservations are invisible and do not require a client refresh hint.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String openLease(String user, String name, String table, String pk) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (store.openLease(user, name, table, pk));
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve expiry as a rejoin signal, distinct from infrastructure failure.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public boolean renewLease(String user, String name, String connection, String table, String pk, String mode, String field, Long sequence) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
boolean accepted = store.renewLease(user, name, connection, table, pk, mode, field, sequence);
|
||||
if(accepted)
|
||||
{
|
||||
changed(table, pk);
|
||||
}
|
||||
return (accepted);
|
||||
}
|
||||
catch(PresenceLeaseExpiredException failure)
|
||||
{
|
||||
throw (failure);
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Do not fall back to isolated local state when a shared store is unavailable.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public boolean heartbeat(String user, String name, String connection, String table, String pk, String mode, String field, Long sequence) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
boolean accepted = store.heartbeat(user, name, connection, table, pk, mode, field, sequence);
|
||||
if(accepted)
|
||||
{
|
||||
changed(table, pk);
|
||||
}
|
||||
return (accepted);
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** If unavailable, the existing lease still expires using Redis server time.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void leave(String user, String connection, String table, String pk, Long sequence) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!store.leave(user, connection, table, pk, sequence))
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
changed(table, pk);
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Failure is explicit; an empty result is reserved for a successfully read room.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<PresenceRegistry.Viewer> snapshot(String table, String pk) throws QServiceUnavailableException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (store.snapshot(table, pk));
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
throw (unavailable());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Never expose client exceptions, endpoint details or credentials to browsers.
|
||||
***************************************************************************/
|
||||
private static QServiceUnavailableException unavailable()
|
||||
{
|
||||
return (new QServiceUnavailableException("Presence is temporarily unavailable. Please retry."));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Best-effort hints carry identifiers only; authorized snapshots remain truth.
|
||||
***************************************************************************/
|
||||
private void changed(String table, String pk)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bus = com.kingsrook.qqq.backend.core.realtime.RealtimeBus.get(QContext.getQInstance());
|
||||
bus.publish(new com.kingsrook.qqq.backend.core.realtime.RealtimeMessage()
|
||||
.withChannel("table:" + table).withEvent(RedisPresenceAudience.EVENT).withAudience(audience)
|
||||
.withData(java.util.Map.of("table", table)));
|
||||
if(pk != null)
|
||||
{
|
||||
bus.publish(new com.kingsrook.qqq.backend.core.realtime.RealtimeMessage()
|
||||
.withChannel("record:" + table + ":" + pk).withEvent(RedisPresenceAudience.EVENT).withAudience(audience)
|
||||
.withData(java.util.Map.of("table", table, "pk", pk)));
|
||||
}
|
||||
}
|
||||
catch(RuntimeException failure)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.logging.QLogger.getLogger(RedisPresenceBackend.class)
|
||||
.warn("Presence hint delivery failed; clients must reconcile through authorized reads");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
/*******************************************************************************
|
||||
** Host configuration for shared presence, including authoritative tenant scope.
|
||||
*******************************************************************************/
|
||||
public class RedisPresenceConfig implements Serializable
|
||||
{
|
||||
private String connectionName = null;
|
||||
private String deployment = null;
|
||||
private String application = null;
|
||||
private QCodeReference scopeProvider = null;
|
||||
private Integer capacity = 2000;
|
||||
|
||||
/***************************************************************************
|
||||
** Inspect configuration without creating a runtime pool or local registry.
|
||||
***************************************************************************/
|
||||
public static RedisPresenceConfig forInstance(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance)
|
||||
{
|
||||
var config = instance.getQBits().values().stream().map(bit -> bit.getConfig())
|
||||
.filter(com.kingsrook.qbits.collaboration.CollaborationQBitConfig.class::isInstance)
|
||||
.map(com.kingsrook.qbits.collaboration.CollaborationQBitConfig.class::cast).findFirst().orElse(null);
|
||||
return (config == null ? null : config.getRedisPresence());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit scope prevents accidental sharing between deployments or tenants.
|
||||
***************************************************************************/
|
||||
public void validate(List<String> errors)
|
||||
{
|
||||
if(connectionName == null || connectionName.isBlank() || deployment == null || deployment.isBlank()
|
||||
|| application == null || application.isBlank() || scopeProvider == null)
|
||||
{
|
||||
errors.add("Redis presence requires a named connection, deployment, application and tenant scope provider");
|
||||
}
|
||||
if(capacity == null || capacity < 1 || capacity > 10000)
|
||||
{
|
||||
errors.add("Redis presence capacity must be between 1 and 10000");
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Return connectionName.
|
||||
***************************************************************************/
|
||||
public String getConnectionName()
|
||||
{
|
||||
return (connectionName);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Configure connectionName.
|
||||
***************************************************************************/
|
||||
public RedisPresenceConfig withConnectionName(String value)
|
||||
{
|
||||
connectionName = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Return deployment.
|
||||
***************************************************************************/
|
||||
public String getDeployment()
|
||||
{
|
||||
return (deployment);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Configure deployment.
|
||||
***************************************************************************/
|
||||
public RedisPresenceConfig withDeployment(String value)
|
||||
{
|
||||
deployment = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Return application.
|
||||
***************************************************************************/
|
||||
public String getApplication()
|
||||
{
|
||||
return (application);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Configure application.
|
||||
***************************************************************************/
|
||||
public RedisPresenceConfig withApplication(String value)
|
||||
{
|
||||
application = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Return scopeProvider.
|
||||
***************************************************************************/
|
||||
public QCodeReference getScopeProvider()
|
||||
{
|
||||
return (scopeProvider);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Configure scopeProvider.
|
||||
***************************************************************************/
|
||||
public RedisPresenceConfig withScopeProvider(QCodeReference value)
|
||||
{
|
||||
scopeProvider = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Return capacity.
|
||||
***************************************************************************/
|
||||
public Integer getCapacity()
|
||||
{
|
||||
return (capacity);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Configure capacity.
|
||||
***************************************************************************/
|
||||
public RedisPresenceConfig withCapacity(Integer value)
|
||||
{
|
||||
capacity = value;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* 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.presence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.redis.api.RedisNamespace;
|
||||
import com.kingsrook.qbits.redis.api.RedisScript;
|
||||
import com.kingsrook.qbits.redis.api.RedisScriptExecutor;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared ephemeral presence. Callers must authorize identity, scope and fields.
|
||||
** Scripts atomically update record/table membership using the Redis server clock.
|
||||
*******************************************************************************/
|
||||
public final class RedisPresenceStore
|
||||
{
|
||||
private static final RedisScript MUTATE = new RedisScript(resource("redis-mutate.lua"));
|
||||
private static final RedisScript SNAPSHOT = new RedisScript(resource("redis-snapshot.lua"));
|
||||
private final RedisScriptExecutor client;
|
||||
private final RedisNamespace namespace;
|
||||
private final long ttlMillis;
|
||||
private final int capacity;
|
||||
|
||||
/***************************************************************************
|
||||
** Limit each indexed room, including departure markers, to bound script work.
|
||||
***************************************************************************/
|
||||
public RedisPresenceStore(RedisScriptExecutor client, RedisNamespace namespace, Duration ttl, int capacity)
|
||||
{
|
||||
this.client = Objects.requireNonNull(client);
|
||||
this.namespace = Objects.requireNonNull(namespace);
|
||||
this.ttlMillis = Objects.requireNonNull(ttl).toMillis();
|
||||
if(ttlMillis < 1 || ttlMillis > Duration.ofMinutes(5).toMillis() || capacity < 1 || capacity > 10000)
|
||||
{
|
||||
throw (new IllegalArgumentException("Presence requires a positive TTL of at most five minutes and capacity of 1 to 10000"));
|
||||
}
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Renew a connection; duplicate or delayed sequences never extend its lease.
|
||||
***************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String field, Long sequence)
|
||||
{
|
||||
if(connection != null && connection.startsWith("lease:"))
|
||||
{
|
||||
throw (new PresenceLeaseExpiredException());
|
||||
}
|
||||
if(!List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode))
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid presence mode"));
|
||||
}
|
||||
return (mutate(user, displayName, connection, table, pk, mode, field, sequence, false, "legacy"));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Reserve an invisible server-issued lease. Null means room capacity refused.
|
||||
** Only a subsequent renewal makes it visible; lost responses expire silently.
|
||||
***************************************************************************/
|
||||
public String openLease(String user, String displayName, String table, String pk)
|
||||
{
|
||||
String connection = "lease:" + java.util.UUID.randomUUID();
|
||||
return (mutate(user, displayName, connection, table, pk, "VIEWING", null, 0L, false, "open") ? connection : null);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Activate or renew existing authority, never recreate missing/departed state.
|
||||
** Callers must reauthorize identity, parent visibility and focused fields.
|
||||
***************************************************************************/
|
||||
public boolean renewLease(String user, String displayName, String connection, String table, String pk, String mode, String field, Long sequence)
|
||||
{
|
||||
if(connection == null || !connection.startsWith("lease:") || sequence == null || sequence < 1)
|
||||
{
|
||||
throw (new IllegalArgumentException("A server-issued lease and positive sequence are required"));
|
||||
}
|
||||
if(!List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode))
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid presence mode"));
|
||||
}
|
||||
return (mutate(user, displayName, connection, table, pk, mode, field, sequence, false, "renew"));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Retain a bounded departure marker so older HTTP renewals cannot undo leave.
|
||||
***************************************************************************/
|
||||
public boolean leave(String user, String connection, String table, String pk, Long sequence)
|
||||
{
|
||||
return (mutate(user, user, connection, table, pk, "VIEWING", null, sequence, true, "legacy"));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Redis failures propagate; they must never be presented as an empty room.
|
||||
***************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> snapshot(String table, String pk)
|
||||
{
|
||||
List<String> keys = roomKeys(table, pk, true);
|
||||
Object result = client.execute(SNAPSHOT, keys, List.of(pk == null ? "" : ":" + encode(pk, 1024)));
|
||||
Map<String, Stored> users = new TreeMap<>();
|
||||
for(Object value : (List<?>) result)
|
||||
{
|
||||
try
|
||||
{
|
||||
Stored entry = JsonUtils.toObject((String) value, Stored.class);
|
||||
if(!entry.departed() && !entry.pending())
|
||||
{
|
||||
users.merge(entry.userId(), entry, (old, next) ->
|
||||
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Invalid stored presence data", e));
|
||||
}
|
||||
}
|
||||
return (users.values().stream().map(entry -> new PresenceRegistry.Viewer(entry.userId(), entry.displayName(), entry.mode(),
|
||||
pk == null ? null : entry.fieldName())).toList());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Validate bounded inputs before sending any Lua command to Redis.
|
||||
***************************************************************************/
|
||||
private boolean mutate(String user, String displayName, String connection, String table, String pk, String mode, String field, Long sequence, boolean departed, String operation)
|
||||
{
|
||||
String member = encode(user, 255) + ":" + encode(connection, 255) + ":" + (pk == null ? "table" : encode(pk, 1024));
|
||||
encode(displayName, 1024);
|
||||
if(field != null)
|
||||
{
|
||||
encode(field, 255);
|
||||
}
|
||||
if(sequence != null && (sequence < 0 || sequence > 9007199254740991L))
|
||||
{
|
||||
throw (new IllegalArgumentException("Presence sequence must be a nonnegative safe integer"));
|
||||
}
|
||||
Stored value = new Stored(user, displayName, mode, pk == null ? null : field, 0, sequence, departed, false, false);
|
||||
Object result = client.execute(MUTATE, roomKeys(table, pk, true), List.of(member, JsonUtils.toJson(value),
|
||||
sequence == null ? "" : sequence.toString(), departed ? "1" : "0", Long.toString(ttlMillis), Integer.toString(capacity), pk == null ? "1" : "0", operation));
|
||||
if(((Number) result).longValue() == -1)
|
||||
{
|
||||
throw (new PresenceLeaseExpiredException());
|
||||
}
|
||||
return (((Number) result).longValue() == 1);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** One table hash tag keeps record/table updates atomic on Redis Cluster slots.
|
||||
***************************************************************************/
|
||||
private List<String> roomKeys(String table, String pk, boolean includeTable)
|
||||
{
|
||||
String base = "qqq:presence:{" + digest("SHA-256", namespace.key(table)) + "}";
|
||||
List<String> keys = new ArrayList<>();
|
||||
if(pk != null)
|
||||
{
|
||||
String record = base + ":record:" + encode(pk, 1024);
|
||||
keys.add(record + ":expiry");
|
||||
keys.add(record + ":state");
|
||||
}
|
||||
if(pk == null || includeTable)
|
||||
{
|
||||
keys.add(base + ":table:expiry");
|
||||
keys.add(base + ":table:state");
|
||||
}
|
||||
return (keys);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Distinguish arbitrary identifiers without allowing delimiter injection.
|
||||
***************************************************************************/
|
||||
private static String encode(String value, int maximumBytes)
|
||||
{
|
||||
if(value == null || value.isBlank() || value.getBytes(StandardCharsets.UTF_8).length > maximumBytes)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid or oversized presence identifier"));
|
||||
}
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Hash public routing identifiers and scripts using JDK algorithms.
|
||||
***************************************************************************/
|
||||
private static String digest(String algorithm, String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance(algorithm).digest(value.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("Required digest algorithm unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Load immutable scripts from the application artifact.
|
||||
***************************************************************************/
|
||||
private static String resource(String name)
|
||||
{
|
||||
try(var stream = RedisPresenceStore.class.getResourceAsStream("/presence/" + name))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IllegalStateException("Missing presence script"));
|
||||
}
|
||||
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Cannot load presence script", e));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Internal lease data, never returned directly to a browser.
|
||||
***************************************************************************/
|
||||
public record Stored(String userId, String displayName, String mode, String fieldName, long at, Long sequence, boolean departed, boolean pending, boolean leased)
|
||||
{
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+6
-15
@@ -27,6 +27,7 @@ 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;
|
||||
@@ -34,7 +35,6 @@ 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.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -90,8 +90,7 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
|
||||
/*******************************************************************************
|
||||
** 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 (FieldAccessHelper.isRecordLabelFullAccess, absent in older QQQ
|
||||
** versions, which means no label).
|
||||
** accessible under the recipient session's field-access policy.
|
||||
*******************************************************************************/
|
||||
public Optional<String> recordLabel(String userId, String table, Serializable recordId) throws QException
|
||||
{
|
||||
@@ -111,7 +110,7 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireVisibleParent(table, recordId);
|
||||
if(labelFullyAccessible(table))
|
||||
if(FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(table)))
|
||||
{
|
||||
GetInput input = new GetInput(table).withPrimaryKey(recordId).withShouldGenerateDisplayValues(true);
|
||||
QRecord record = new GetAction().executeForRecord(input);
|
||||
@@ -127,18 +126,10 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reflective guard: the helper only exists in QQQ versions with secured data.
|
||||
** Notification titles fall back to public table metadata and the record id.
|
||||
*******************************************************************************/
|
||||
private static boolean labelFullyAccessible(String table)
|
||||
public String notificationLabel(String userId, String table, Serializable recordId) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> helper = Class.forName("com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper");
|
||||
return (Boolean.TRUE.equals(helper.getMethod("isRecordLabelFullAccess", QTableMetaData.class).invoke(null, QContext.getQInstance().getTable(table))));
|
||||
}
|
||||
catch(ReflectiveOperationException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (recordLabel(userId, table, recordId).orElse(QContext.getQInstance().getTable(table).getLabel() + " #" + recordId));
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,8 @@ 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,
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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,75 @@
|
||||
-- All room keys share a table hash tag. Redis TIME owns lease expiry.
|
||||
-- Validate every pair before changing either record or table membership.
|
||||
for i = 1, #KEYS, 2 do
|
||||
if redis.call('ZCARD', KEYS[i]) ~= redis.call('HLEN', KEYS[i + 1]) then
|
||||
return redis.error_reply('Presence room state is incomplete')
|
||||
end
|
||||
end
|
||||
local clock = redis.call('TIME')
|
||||
local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)
|
||||
local member = ARGV[1]
|
||||
local ttl = tonumber(ARGV[5])
|
||||
local capacity = tonumber(ARGV[6])
|
||||
local sequence = tonumber(ARGV[3])
|
||||
local departed = ARGV[4] == '1'
|
||||
|
||||
for i = 1, #KEYS, 2 do
|
||||
local expired = redis.call('ZRANGEBYSCORE', KEYS[i], '-inf', now)
|
||||
for _, id in ipairs(expired) do
|
||||
redis.call('ZREM', KEYS[i], id)
|
||||
redis.call('HDEL', KEYS[i + 1], id)
|
||||
end
|
||||
end
|
||||
|
||||
local previous = redis.call('HGET', KEYS[2], member)
|
||||
-- A record lease and its table projection are written atomically. If one is
|
||||
-- missing while the other is live, do not erase surviving ordering/tombstones.
|
||||
if #KEYS == 4 then
|
||||
local tablePrevious = redis.call('HGET', KEYS[4], member)
|
||||
if (previous and not tablePrevious) or (tablePrevious and not previous) then
|
||||
return redis.error_reply('Presence room state is incomplete')
|
||||
end
|
||||
end
|
||||
local old = previous and cjson.decode(previous) or nil
|
||||
local operation = ARGV[8] or 'legacy'
|
||||
-- Renewal authority is issued by open, not inferred from a caller's sequence.
|
||||
-- Missing/expired/departed leases cannot be recreated by delayed HTTP requests.
|
||||
if operation == 'renew' and (not old or not old.leased or old.departed) then
|
||||
return -1
|
||||
end
|
||||
if operation == 'open' and old then return -1 end
|
||||
if operation == 'legacy' and not departed and old and old.leased then return -1 end
|
||||
if old then
|
||||
local oldSequence = old.sequence
|
||||
if oldSequence == cjson.null then oldSequence = nil end
|
||||
if oldSequence then
|
||||
if not sequence or sequence < oldSequence or (sequence == oldSequence and (not departed or old.departed)) then
|
||||
return 1
|
||||
end
|
||||
elseif not sequence and old.at == now and old.departed and not departed then
|
||||
return 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Check every room before writing any membership, avoiding a partial join.
|
||||
for i = 1, #KEYS, 2 do
|
||||
if redis.call('HEXISTS', KEYS[i + 1], member) == 0 and redis.call('ZCARD', KEYS[i]) >= capacity then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
local data = cjson.decode(ARGV[2])
|
||||
data.at = now
|
||||
data.sequence = sequence or cjson.null
|
||||
data.departed = departed
|
||||
data.pending = operation == 'open'
|
||||
data.leased = operation ~= 'legacy' or (old and old.leased) or false
|
||||
for i = 1, #KEYS, 2 do
|
||||
-- Record scope comes first; table scope must never carry focused-field data.
|
||||
if i > 1 or ARGV[7] == '1' then data.fieldName = cjson.null end
|
||||
redis.call('HSET', KEYS[i + 1], member, cjson.encode(data))
|
||||
redis.call('ZADD', KEYS[i], now + ttl, member)
|
||||
redis.call('PEXPIRE', KEYS[i], ttl)
|
||||
redis.call('PEXPIRE', KEYS[i + 1], ttl)
|
||||
end
|
||||
return 1
|
||||
@@ -0,0 +1,39 @@
|
||||
-- A partially evicted room is unavailable, not empty. Do not silently discard it.
|
||||
for i = 1, #KEYS, 2 do
|
||||
if redis.call('ZCARD', KEYS[i]) ~= redis.call('HLEN', KEYS[i + 1]) then
|
||||
return redis.error_reply('Presence room state is incomplete')
|
||||
end
|
||||
end
|
||||
local clock = redis.call('TIME')
|
||||
local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)
|
||||
for i = 1, #KEYS, 2 do
|
||||
local expired = redis.call('ZRANGEBYSCORE', KEYS[i], '-inf', now)
|
||||
for _, id in ipairs(expired) do
|
||||
redis.call('ZREM', KEYS[i], id)
|
||||
redis.call('HDEL', KEYS[i + 1], id)
|
||||
end
|
||||
end
|
||||
local members = redis.call('ZRANGE', KEYS[1], 0, -1)
|
||||
-- Table membership is bounded by the configured capacity. Compare the record's
|
||||
-- projection without key discovery or cross-slot reads.
|
||||
if #KEYS == 4 then
|
||||
local projected = 0
|
||||
for _, id in ipairs(redis.call('ZRANGE', KEYS[3], 0, -1)) do
|
||||
if string.sub(id, -string.len(ARGV[1])) == ARGV[1] then
|
||||
projected = projected + 1
|
||||
if not redis.call('ZSCORE', KEYS[1], id) then
|
||||
return redis.error_reply('Presence room state is incomplete')
|
||||
end
|
||||
end
|
||||
end
|
||||
if projected ~= #members then
|
||||
return redis.error_reply('Presence room state is incomplete')
|
||||
end
|
||||
end
|
||||
local result = {}
|
||||
for _, id in ipairs(members) do
|
||||
local data = redis.call('HGET', KEYS[2], id)
|
||||
if not data then return redis.error_reply('Presence room state is incomplete') end
|
||||
table.insert(result, data)
|
||||
end
|
||||
return result
|
||||
@@ -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,10 +57,10 @@ 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);
|
||||
assertThat(extension.getSpecs()).hasSize(48);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -37,6 +37,7 @@ 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;
|
||||
@@ -62,6 +63,23 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
*******************************************************************************/
|
||||
class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
{
|
||||
/***************************************************************************
|
||||
** Shared presence has an explicit unavailable response, never a local fallback.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void unavailablePresenceHttp() throws Exception
|
||||
{
|
||||
config.withRedisPresence(new com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig()
|
||||
.withConnectionName("not-started").withDeployment("test").withApplication("fixture")
|
||||
.withScopeProvider(new QCodeReference(RedisPresenceServiceTest.TenantScope.class)));
|
||||
assertError(503, "GET", "/presence?table=document&pk=1", null, "alice");
|
||||
assertError(503, "POST", "/presence/leases", "{\"table\":\"document\",\"pk\":\"1\"}", "alice");
|
||||
assertError(503, "POST", "/presence/leases/renew", "{\"connectionId\":\"lease:missing\",\"table\":\"document\",\"pk\":\"1\",\"mode\":\"VIEWING\",\"sequence\":1}", "alice");
|
||||
assertError(400, "POST", "/presence", "{\"connectionId\":\"tab\",\"table\":\"document\",\"pk\":\"1\",\"mode\":\"VIEWING\"}", "alice");
|
||||
assertError(503, "DELETE", "/presence", "{\"connectionId\":\"tab\",\"table\":\"document\",\"pk\":\"1\"}", "alice");
|
||||
assertError(404, "GET", "/presence?table=document&pk=3", null, "alice");
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private final HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
private QApplicationJavalinServer server;
|
||||
@@ -230,6 +248,27 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
assertThat(ok("GET", "/presence?table=document&pk=1", null).get("viewers")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** HTTP-issued leases are invisible until activated and expire explicitly.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void issuedPresenceHttp() throws Exception
|
||||
{
|
||||
String lease = ok("POST", "/presence/leases", "{\"table\":\"document\",\"pk\":\"01\"}").get("connectionId").asText();
|
||||
assertThat(lease).startsWith("lease:");
|
||||
assertThat(ok("GET", "/presence?table=document&pk=1", null).get("viewers")).isEmpty();
|
||||
String body = new ObjectMapper().writeValueAsString(Map.of("connectionId", lease, "table", "document", "pk", "1", "mode", "EDITING", "fieldName", "title", "sequence", 1));
|
||||
assertThat(ok("POST", "/presence/leases/renew", body).get("viewers")).hasSize(1);
|
||||
assertError(404, "POST", "/presence/leases", "{\"table\":\"document\",\"pk\":\"3\"}", "alice");
|
||||
assertError(404, "POST", "/presence/leases/renew", body, "outsider");
|
||||
assertError(400, "POST", "/presence/leases/renew", body.replace("\"sequence\":1", "\"sequence\":0"), "alice");
|
||||
String leave = new ObjectMapper().writeValueAsString(Map.of("connectionId", lease, "table", "document", "pk", "1", "sequence", 2));
|
||||
ok("DELETE", "/presence", leave);
|
||||
var expired = json(request("POST", "/presence/leases/renew", body, "alice"), 409);
|
||||
assertThat(expired.get("code").asText()).isEqualTo("PRESENCE_LEASE_EXPIRED");
|
||||
assertThat(ok("GET", "/presence?table=document&pk=1", null).get("viewers")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every personal operation and offset cursor runs through the HTTP boundary.
|
||||
*******************************************************************************/
|
||||
@@ -401,6 +440,21 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
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");
|
||||
json(request("POST", "/records/document/1/comments", "{\"body\":\"Review @[Alice](user:alice)\"}", "carol"), 200);
|
||||
ok("POST", "/records/document/1/comments", "{\"body\":\"Review @[Carol](user:carol)\"}");
|
||||
// Delivery is external to this HTTP fixture; persist its receipt before testing read state.
|
||||
for(var mention : CollaborationRecords.query(com.kingsrook.qbits.collaboration.model.CollaborationTables.MENTION,
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()))
|
||||
{
|
||||
CollaborationRecords.update(com.kingsrook.qbits.collaboration.model.CollaborationTables.MENTION,
|
||||
new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("id", mention.getValue("id")).withValue("notifiedAt", java.time.Instant.now()));
|
||||
}
|
||||
assertThat(ok("GET", "/my-work", null).get("unreadMentionCount").asLong()).isPositive();
|
||||
assertThat(json(request("GET", "/my-work", null, "carol"), 200).get("unreadMentionCount").asLong()).isEqualTo(1);
|
||||
ok("POST", "/my-work/mentions/read", null);
|
||||
assertThat(ok("GET", "/my-work", null).get("unreadMentionCount").asLong()).isZero();
|
||||
assertThat(json(request("GET", "/my-work", null, "carol"), 200).get("unreadMentionCount").asLong()).isEqualTo(1);
|
||||
ok("POST", "/my-work/mentions/read", null);
|
||||
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();
|
||||
@@ -431,8 +485,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");
|
||||
@@ -500,15 +562,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.3.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();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
+223
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -146,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();
|
||||
@@ -228,6 +228,21 @@ class CollaborationTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Home tables leave column widths to the frontend, so narrow cards wrap
|
||||
** instead of scrolling sideways (issue #21).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeTablesHaveNoFixedColumnWidths() throws Exception
|
||||
{
|
||||
for(TableData table : java.util.List.of(CollaborationHome.tags(), CollaborationHome.fields(), CollaborationHome.presence()))
|
||||
{
|
||||
assertThat(table.getColumns()).isNotEmpty().allSatisfy(column -> assertThat(column.getWidth()).isNull());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty states explain what has not been populated.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -179,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,144 @@
|
||||
/*
|
||||
* 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.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise a consumer classpath without the optional Redis implementation/driver.
|
||||
*******************************************************************************/
|
||||
class OptionalRedisConsumerTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** An isolated loader must not resolve Redis from the test runner's parent.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void memoryPresenceWithoutRedis() throws Exception
|
||||
{
|
||||
String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path"));
|
||||
URL[] urls = Arrays.stream(classpath.split(java.io.File.pathSeparator))
|
||||
.filter(entry -> !entry.replace('\\', '/').contains("/qbit-redis/")
|
||||
&& !entry.replace('\\', '/').contains("/qbit-email/")
|
||||
&& !Path.of(entry).getFileName().toString().startsWith("jedis-"))
|
||||
.map(entry ->
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Path.of(entry).toUri().toURL());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalArgumentException(e));
|
||||
}
|
||||
}).toArray(URL[]::new);
|
||||
ClassLoader previous = Thread.currentThread().getContextClassLoader();
|
||||
try(var loader = new URLClassLoader(urls, ClassLoader.getPlatformClassLoader()))
|
||||
{
|
||||
assertThrows(ClassNotFoundException.class, () -> loader.loadClass("com.kingsrook.qbits.redis.api.RedisNamespace"));
|
||||
assertThrows(ClassNotFoundException.class, () -> loader.loadClass("redis.clients.jedis.RedisClient"));
|
||||
assertThrows(ClassNotFoundException.class, () -> loader.loadClass("com.kingsrook.qbits.email.api.EmailApi"));
|
||||
Thread.currentThread().setContextClassLoader(loader);
|
||||
loader.loadClass(Probe.class.getName()).getMethod("exercise").invoke(null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.currentThread().setContextClassLoader(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Loaded in the isolated consumer, including metadata enrichment and delivery.
|
||||
***************************************************************************/
|
||||
public static class Probe extends PersonalServiceFixture
|
||||
{
|
||||
/************************************************************************
|
||||
** Real host setup and authorized presence work without Redis artifacts.
|
||||
************************************************************************/
|
||||
public static void exercise() throws Exception
|
||||
{
|
||||
var probe = new Probe();
|
||||
try
|
||||
{
|
||||
probe.setUp();
|
||||
var service = new com.kingsrook.qbits.collaboration.presence.PresenceService();
|
||||
String lease = service.openLease("document", "1");
|
||||
assertTrue(service.snapshot("document", "1").isEmpty());
|
||||
assertEquals(1, service.renewLease(lease, "document", "1", "VIEWING", null, 1L).size());
|
||||
service.leave(lease, "document", "1", 2L);
|
||||
assertTrue(service.snapshot("document", "1").isEmpty());
|
||||
assertFalse(new com.kingsrook.qbits.collaboration.presence.RedisPresenceAudience().allows("untrusted"));
|
||||
probe.config.withUserSessionProvider(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(RecipientSessions.class));
|
||||
assertEquals(java.util.Optional.of(true), new com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker()
|
||||
.canRead("alice", "document", "1"));
|
||||
var notice = new com.kingsrook.qbits.collaboration.notify.CollaborationNotification("alice", "Test", "Test",
|
||||
com.kingsrook.qbits.collaboration.notify.CollaborationNotification.Category.MENTION, "document", "1", "/app/document/1");
|
||||
var receipts = new java.util.concurrent.atomic.AtomicInteger();
|
||||
com.kingsrook.qbits.collaboration.notify.Notifications.send(notice, () -> receipts.incrementAndGet());
|
||||
assertEquals(0, receipts.get(), "Missing email must not acknowledge delivery");
|
||||
probe.config.withNotifier(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(AcceptingNotifier.class));
|
||||
com.kingsrook.qbits.collaboration.notify.Notifications.send(notice, () -> receipts.incrementAndGet());
|
||||
assertEquals(1, receipts.get(), "Explicit host notifier must still work without email");
|
||||
}
|
||||
finally
|
||||
{
|
||||
probe.tearDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Host session lookup makes the notification eligible for actual delivery.
|
||||
***************************************************************************/
|
||||
public static class RecipientSessions implements com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface
|
||||
{
|
||||
/************************************************************************
|
||||
** Fixture recipient has access to the parent record in tenant a.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public java.util.Optional<com.kingsrook.qqq.backend.core.model.session.QSession> sessionForUser(String userId)
|
||||
{
|
||||
return (java.util.Optional.of(new com.kingsrook.qqq.backend.core.model.session.QSession()
|
||||
.withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference(userId))
|
||||
.withPermissions("document.read").withSecurityKeyValue("tenant", "a")));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A host transport that accepts delivery without the optional email adapter.
|
||||
***************************************************************************/
|
||||
public static class AcceptingNotifier implements com.kingsrook.qbits.collaboration.notify.CollaborationNotifier
|
||||
{
|
||||
/************************************************************************
|
||||
** Test transport has no external delivery side effects.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public void notify(com.kingsrook.qbits.collaboration.notify.CollaborationNotification notification)
|
||||
{
|
||||
assertEquals("alice", notification.recipientUserId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,163 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
*******************************************************************************/
|
||||
class PresenceTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Issued service operations retain identity, authorization and cleanup rules.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void issuedServiceRechecksAccessAndDepartureOrder() throws Exception
|
||||
{
|
||||
var service = new PresenceService();
|
||||
String lease = service.openLease("document", "01");
|
||||
assertThat(service.snapshot("document", "1")).isEmpty();
|
||||
assertThatThrownBy(() -> service.renewLease(lease, "document", "1", "EDITING", "missing", 1L))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QBadRequestException.class);
|
||||
assertThat(service.renewLease(lease, "document", "1", "EDITING", "title", 1L)).hasSize(1);
|
||||
assertThatThrownBy(() -> service.leave(lease, "document", "1", null))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QBadRequestException.class);
|
||||
login("bob");
|
||||
assertThatThrownBy(() -> service.renewLease(lease, "document", "1", "EDITING", "title", 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
login("alice");
|
||||
QContext.getQSession().getPermissions().remove("document.read");
|
||||
assertThatThrownBy(() -> service.openLease("document", "1")).isInstanceOf(QNotFoundException.class);
|
||||
assertThatThrownBy(() -> service.renewLease(lease, "document", "1", "EDITING", "title", 3L))
|
||||
.isInstanceOf(QNotFoundException.class);
|
||||
assertThat(PresenceRegistry.get(instance).snapshot("document", "1")).isEmpty();
|
||||
login("alice");
|
||||
assertThatThrownBy(() -> service.renewLease(lease, "document", "1", "EDITING", "title", 4L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Local issued authority is bounded and cannot be recreated by late requests.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void localIssuedLeasesRequireExistingAuthority() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var local = new PresenceRegistry(bus, clock, 1); var other = new PresenceRegistry(bus, clock, 1))
|
||||
{
|
||||
String lease = local.openLease("alice", "Alice", "document", "1");
|
||||
assertThat(lease).startsWith("lease:");
|
||||
assertThat(local.snapshot("document", "1")).isEmpty();
|
||||
assertThat(local.openLease("bob", "Bob", "document", "2")).isNull();
|
||||
assertThatThrownBy(() -> other.renewLease("alice", "Alice", lease, "document", "1", "EDITING", "title", 1L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThatThrownBy(() -> local.renewLease("bob", "Bob", lease, "document", "1", "EDITING", "title", 1L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
local.renewLease("alice", "Alice", lease, "document", "1", "EDITING", "title", 1L);
|
||||
clock.advance(1000);
|
||||
local.renewLease("alice", "Alice", lease, "document", "1", "VIEWING", null, 1L);
|
||||
assertThat(local.snapshot("document", "1").getFirst().mode()).isEqualTo("EDITING");
|
||||
clock.advance(PresenceRegistry.TTL_MILLIS - 1000);
|
||||
assertThatThrownBy(() -> local.renewLease("alice", "Alice", lease, "document", "1", "EDITING", "title", 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(local.snapshot("document", "1")).isEmpty();
|
||||
String replacement = local.openLease("alice", "Alice", "document", "1");
|
||||
local.renewLease("alice", "Alice", replacement, "document", "1", "EDITING", "title", 1L);
|
||||
local.leave("alice", replacement, "document", "1", 3L);
|
||||
assertThatThrownBy(() -> local.renewLease("alice", "Alice", replacement, "document", "1", "EDITING", "title", 4L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
clock.advance(PresenceRegistry.TTL_MILLIS);
|
||||
assertThatThrownBy(() -> local.heartbeat("alice", "Alice", replacement, "document", "1", "EDITING", "title", 5L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThatThrownBy(() -> local.renewLease("alice", "Alice", replacement, "document", "1", "EDITING", "title", 5L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(local.snapshot("document", "1")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delayed HTTP renewals and departures retain client order across replicas.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void clientSequenceOrdersDelayedRequests() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, 10); var second = new PresenceRegistry(bus, clock, 10))
|
||||
{
|
||||
first.heartbeat("alice", "Alice", "tab", "document", "1", "EDITING", "title", 2L);
|
||||
clock.advance(10);
|
||||
second.heartbeat("alice", "Alice", "tab", "document", "1", "EDITING", "old", 1L);
|
||||
assertThat(first.snapshot("document", "1").getFirst().fieldName()).isEqualTo("title");
|
||||
second.leave("alice", "tab", "document", "1", 3L);
|
||||
clock.advance(10);
|
||||
first.heartbeat("alice", "Alice", "tab", "document", "1", "EDITING", "title", 2L);
|
||||
assertThat(first.snapshot("document", "1")).isEmpty();
|
||||
assertThat(second.snapshot("document", "1")).isEmpty();
|
||||
first.heartbeat("alice", "Alice", "tab", "document", "1", "VIEWING", null, 4L);
|
||||
second.leave("alice", "tab", "document", "1", 3L);
|
||||
assertThat(second.snapshot("document", "1")).hasSize(1);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Departure keeps unknown-target errors without preventing revoked cleanup.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void departureRejectsUnknownTable() throws Exception
|
||||
{
|
||||
var service = new PresenceService();
|
||||
assertThatThrownBy(() -> service.leave("valid-connection", "nonexistent", null))
|
||||
.isInstanceOf(QNotFoundException.class).hasMessage("Not found");
|
||||
service.heartbeat("valid-connection", "document", "1", "VIEWING", null);
|
||||
QContext.getQSession().getPermissions().remove("document.read");
|
||||
service.leave("valid-connection", "document", "1");
|
||||
assertThat(PresenceRegistry.get(instance).snapshot("document", "1")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A departure wins over delayed and same-timestamp renewals on every pod.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void departedLeaseCannotBeResurrectedByOldHeartbeat() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var registry = new PresenceRegistry(bus, clock, 2))
|
||||
{
|
||||
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
|
||||
bus.subscribe(events::add);
|
||||
registry.heartbeat("alice", "Alice", "one", "document", "1", "EDITING", "title");
|
||||
var old = events.stream().filter(event -> event.getEvent().equals("presence.heartbeat")).findFirst().orElseThrow();
|
||||
registry.leave("alice", "one", "document", "1");
|
||||
bus.publish(old);
|
||||
assertThat(registry.snapshot("document", "1")).isEmpty();
|
||||
clock.advance(1);
|
||||
registry.heartbeat("alice", "Alice", "one", "document", "1", "VIEWING", null);
|
||||
assertThat(registry.snapshot("document", "1")).hasSize(1);
|
||||
clock.advance(PresenceRegistry.TTL_MILLIS);
|
||||
assertThat(registry.snapshot("document", "1")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Focus is validated for the sender and projected again for each recipient.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void focusedFieldsRespectVisibilityOnEveryResponse() throws Exception
|
||||
{
|
||||
var table = instance.getTable("document");
|
||||
table.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("hidden", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING).withIsHidden(true));
|
||||
table.withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("password", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.PASSWORD));
|
||||
var service = new PresenceService();
|
||||
for(String field : List.of("hidden", "password", "missing"))
|
||||
{
|
||||
assertThatThrownBy(() -> service.heartbeat("alice-tab", "document", "1", "EDITING", field))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QBadRequestException.class);
|
||||
}
|
||||
service.heartbeat("alice-tab", "document", "1", "EDITING", "title");
|
||||
login("bob");
|
||||
assertThat(service.snapshot("document", "1").getFirst().fieldName()).isEqualTo("title");
|
||||
table.getField("title").setIsHidden(true);
|
||||
assertThat(service.snapshot("document", "1").getFirst().fieldName()).isNull();
|
||||
assertThat(service.heartbeat("bob-tab", "document", "1", "VIEWING", null))
|
||||
.allSatisfy(viewer -> assertThat(viewer.fieldName()).isNull());
|
||||
assertThat(PresenceRegistry.get(instance).snapshot("document", "1")).anySatisfy(viewer -> assertThat(viewer.fieldName()).isEqualTo("title"));
|
||||
table.getField("title").setIsHidden(false);
|
||||
assertThat(service.snapshot("document", "1")).anySatisfy(viewer -> assertThat(viewer.fieldName()).isEqualTo("title"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Renewals replicate even without a visible change; tabs merge until all expire.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* 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.qbits.collaboration.presence.PresenceScopeProvider;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig;
|
||||
import com.kingsrook.qbits.redis.RedisConnectionConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitProducer;
|
||||
import com.kingsrook.qbits.redis.RedisRuntimeService;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QServiceUnavailableException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the authenticated service boundary against a real Redis runtime.
|
||||
*******************************************************************************/
|
||||
class RedisPresenceServiceTest extends PersonalServiceFixture
|
||||
{
|
||||
/***************************************************************************
|
||||
** Separate application runtimes share leases and deliver hints through Redis.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void twoNodesShareSixViewersAndTwoEditors() throws Exception
|
||||
{
|
||||
var second = new PersonalServiceFixture() { };
|
||||
second.setUp();
|
||||
var firstRuntime = new RedisRuntimeService();
|
||||
var secondRuntime = new RedisRuntimeService();
|
||||
var firstTransport = new com.kingsrook.qbits.redis.RedisRealtimeRuntimeService();
|
||||
var secondTransport = new com.kingsrook.qbits.redis.RedisRealtimeRuntimeService();
|
||||
try(var redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
var nodes = java.util.List.of(instance, second.instance);
|
||||
var configs = java.util.List.of(config, second.config);
|
||||
for(int i = 0; i < nodes.size(); i++)
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.realtime.RealtimeBus.close(nodes.get(i));
|
||||
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("presence",
|
||||
new RedisConnectionConfig().withHost(redis.getHost()).withPort(redis.getMappedPort(6379)).withTls(false))
|
||||
.withRealtime("presence", "test", "multi-node")).produce(nodes.get(i));
|
||||
configs.get(i).withRedisPresence(new RedisPresenceConfig().withConnectionName("presence").withDeployment("test")
|
||||
.withApplication("multi-node").withScopeProvider(new QCodeReference(TenantScope.class)));
|
||||
}
|
||||
firstRuntime.start(instance);
|
||||
secondRuntime.start(second.instance);
|
||||
firstTransport.start(instance);
|
||||
secondTransport.start(second.instance);
|
||||
org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(5), () ->
|
||||
{
|
||||
for(var node : nodes)
|
||||
{
|
||||
while(!((com.kingsrook.qbits.redis.RedisRealtimeBus) com.kingsrook.qqq.backend.core.realtime.RealtimeBus.getIfStarted(node).orElseThrow()).isListening())
|
||||
{
|
||||
Thread.sleep(10);
|
||||
}
|
||||
}
|
||||
});
|
||||
try(var hub = new com.kingsrook.qqq.middleware.javalin.realtime.RealtimeHub(second.instance))
|
||||
{
|
||||
var observer = session("observer");
|
||||
QContext.init(second.instance, observer);
|
||||
var stream = hub.connect(observer, null);
|
||||
hub.subscriptions(observer, stream.getConnectionId(), java.util.List.of("record:document:1"), java.util.List.of());
|
||||
var otherTenant = session("other-tenant");
|
||||
otherTenant.clearSecurityKeyValues();
|
||||
otherTenant.withSecurityKeyValue("tenant", "b");
|
||||
QContext.init(second.instance, otherTenant);
|
||||
var otherStream = hub.connect(otherTenant, null);
|
||||
hub.subscriptions(otherTenant, otherStream.getConnectionId(), java.util.List.of("table:document"), java.util.List.of());
|
||||
var service = new PresenceService();
|
||||
var leases = new java.util.ArrayList<String>();
|
||||
for(int i = 0; i < 6; i++)
|
||||
{
|
||||
QContext.init(nodes.get((i + 1) % 2), session("user" + i));
|
||||
String lease = service.openLease("document", "1");
|
||||
leases.add(lease);
|
||||
assertThat(service.snapshot("document", "1")).hasSize(i);
|
||||
QContext.init(nodes.get(i % 2), session("user" + i));
|
||||
service.renewLease(lease, "document", "1", i < 2 ? "EDITING" : "VIEWING", i < 2 ? "title" : null, 1L);
|
||||
if(i == 0)
|
||||
{
|
||||
// Node one activates a lease opened on node two; its hint crosses nodes.
|
||||
org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(5), () ->
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
var frame = stream.poll(200);
|
||||
if(frame != null && "presence.invalidated".equals(frame.event()))
|
||||
{
|
||||
assertThat(frame.data()).containsOnlyKeys("table", "pk").containsEntry("pk", "1");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for(var node : nodes)
|
||||
{
|
||||
QContext.init(node, observer);
|
||||
var viewers = service.snapshot("document", "1");
|
||||
assertThat(viewers).hasSize(6);
|
||||
assertThat(viewers.stream().filter(viewer -> "EDITING".equals(viewer.mode())).count()).isEqualTo(2);
|
||||
assertThat(service.snapshot("document", null)).hasSize(6);
|
||||
}
|
||||
// A later unscoped marker proves the other tenant's stream processed
|
||||
// the earlier hints, instead of passing on an arbitrary short timeout.
|
||||
com.kingsrook.qqq.backend.core.realtime.RealtimeBus.getIfStarted(instance).orElseThrow().publish(
|
||||
new com.kingsrook.qqq.backend.core.realtime.RealtimeMessage().withChannel("table:document")
|
||||
.withEvent("test.barrier").withData(java.util.Map.of()));
|
||||
org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(5), () ->
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
var frame = otherStream.poll(200);
|
||||
if(frame != null)
|
||||
{
|
||||
assertThat(frame.event()).isNotEqualTo("presence.invalidated");
|
||||
if("test.barrier".equals(frame.event()))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
for(var node : nodes)
|
||||
{
|
||||
QContext.init(node, otherTenant);
|
||||
assertThat(service.snapshot("document", null)).isEmpty();
|
||||
assertThat(service.snapshot("document", "3")).isEmpty();
|
||||
assertThatThrownBy(() -> service.snapshot("document", "1")).isInstanceOf(QNotFoundException.class);
|
||||
}
|
||||
QContext.init(second.instance, session("user0"));
|
||||
service.leave(leases.getFirst(), "document", "1", 2L);
|
||||
QContext.init(instance, observer);
|
||||
assertThat(service.snapshot("document", "1")).hasSize(5);
|
||||
var firstBus = (com.kingsrook.qbits.redis.RedisRealtimeBus) com.kingsrook.qqq.backend.core.realtime.RealtimeBus.getIfStarted(instance).orElseThrow();
|
||||
var secondBus = (com.kingsrook.qbits.redis.RedisRealtimeBus) com.kingsrook.qqq.backend.core.realtime.RealtimeBus.getIfStarted(second.instance).orElseThrow();
|
||||
firstBus.publish(new com.kingsrook.qqq.backend.core.realtime.RealtimeMessage().withChannel("record:document:1")
|
||||
.withEvent("test.drained").withData(java.util.Map.of()));
|
||||
awaitEvent(stream, "test.drained");
|
||||
long reconnects = secondBus.metrics().reconnects();
|
||||
assertThat(redis.execInContainer("redis-cli", "CLIENT", "KILL", "TYPE", "pubsub").getExitCode()).isZero();
|
||||
assertThat(awaitEvent(stream, "resync").data()).isEmpty();
|
||||
org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(5), () ->
|
||||
{
|
||||
while(!firstBus.isListening() || !secondBus.isListening() || secondBus.metrics().reconnects() <= reconnects)
|
||||
{
|
||||
Thread.sleep(10);
|
||||
}
|
||||
});
|
||||
// The subscription failure does not erase authoritative presence leases.
|
||||
QContext.init(second.instance, observer);
|
||||
assertThat(service.snapshot("document", "1")).hasSize(5);
|
||||
QContext.init(instance, session("user0"));
|
||||
assertThatThrownBy(() -> service.renewLease(leases.getFirst(), "document", "1", "EDITING", "title", 3L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
String replacement = service.openLease("document", "1");
|
||||
service.renewLease(replacement, "document", "1", "EDITING", "title", 1L);
|
||||
assertThat(awaitEvent(stream, "presence.invalidated").data()).containsEntry("pk", "1");
|
||||
QContext.init(second.instance, observer);
|
||||
assertThat(service.snapshot("document", "1")).hasSize(6);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
firstTransport.stop();
|
||||
secondTransport.stop();
|
||||
firstRuntime.afterApplicationStop();
|
||||
secondRuntime.afterApplicationStop();
|
||||
second.tearDown();
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Wait for a stream event with a bounded deadline, consuming older frames.
|
||||
***************************************************************************/
|
||||
private static com.kingsrook.qqq.middleware.javalin.realtime.RealtimeHub.Frame awaitEvent(
|
||||
com.kingsrook.qqq.middleware.javalin.realtime.RealtimeHub.Attachment stream, String event)
|
||||
{
|
||||
return (org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(5), () ->
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
var frame = stream.poll(200);
|
||||
if(frame != null && event.equals(frame.event()))
|
||||
{
|
||||
return (frame);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Each simulated browser has an independent authenticated host session.
|
||||
***************************************************************************/
|
||||
private static com.kingsrook.qqq.backend.core.model.session.QSession session(String user)
|
||||
{
|
||||
return (new com.kingsrook.qqq.backend.core.model.session.QSession()
|
||||
.withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference(user))
|
||||
.withPermissions("document.read", "document.write").withSecurityKeyValue("tenant", "a"));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Redis preserves permission projection and fails explicitly during outages.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void permissionsIsolationAndUnavailable() throws Exception
|
||||
{
|
||||
try(var redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("presence",
|
||||
new RedisConnectionConfig().withHost(redis.getHost()).withPort(redis.getMappedPort(6379)).withTls(false)
|
||||
.withConnectionTimeoutMillis(200).withSocketTimeoutMillis(200))).produce(instance);
|
||||
config.withRedisPresence(new RedisPresenceConfig().withConnectionName("presence").withDeployment("test")
|
||||
.withApplication("fixture").withScopeProvider(new QCodeReference(TenantScope.class)));
|
||||
var runtime = new RedisRuntimeService();
|
||||
try
|
||||
{
|
||||
runtime.start(instance);
|
||||
var service = new PresenceService();
|
||||
String aliceLease;
|
||||
try(var hub = new com.kingsrook.qqq.middleware.javalin.realtime.RealtimeHub(instance))
|
||||
{
|
||||
var viewer = hub.connect(QContext.getQSession(), null);
|
||||
viewer.poll(100);
|
||||
hub.subscriptions(QContext.getQSession(), viewer.getConnectionId(), java.util.List.of("table:document", "record:document:1"), java.util.List.of());
|
||||
login("other-tenant");
|
||||
QContext.getQSession().clearSecurityKeyValues();
|
||||
QContext.getQSession().withSecurityKeyValue("tenant", "b");
|
||||
var other = hub.connect(QContext.getQSession(), null);
|
||||
other.poll(100);
|
||||
hub.subscriptions(QContext.getQSession(), other.getConnectionId(), java.util.List.of("table:document"), java.util.List.of());
|
||||
login("alice");
|
||||
assertThatThrownBy(() -> service.heartbeat("alice-tab", "document", "01", "EDITING", "title", 1L))
|
||||
.isInstanceOf(QBadRequestException.class).hasMessageContaining("server-issued lease");
|
||||
aliceLease = service.openLease("document", "01");
|
||||
service.renewLease(aliceLease, "document", "01", "EDITING", "title", 1L);
|
||||
var tableHint = viewer.poll(1000);
|
||||
var recordHint = viewer.poll(1000);
|
||||
assertThat(tableHint.event()).isEqualTo("presence.invalidated");
|
||||
assertThat(tableHint.data()).containsOnlyKeys("table");
|
||||
assertThat(recordHint.data()).containsOnlyKeys("table", "pk").containsEntry("pk", "1");
|
||||
assertThat(other.poll(20)).isNull();
|
||||
}
|
||||
admin();
|
||||
var adminPresence = com.kingsrook.qbits.collaboration.app.CollaborationHome.presence();
|
||||
assertThat(adminPresence.getRows()).hasSize(1);
|
||||
assertThat(adminPresence.getRows().getFirst()).containsEntry("table", "Documents").containsEntry("viewers", 1);
|
||||
QContext.getQSession().clearSecurityKeyValues();
|
||||
QContext.getQSession().withSecurityKeyValue("tenant", "b");
|
||||
assertThat(com.kingsrook.qbits.collaboration.app.CollaborationHome.presence().getRows()).isEmpty();
|
||||
login("bob");
|
||||
assertThat(com.kingsrook.qbits.collaboration.app.CollaborationHome.presence().getRows()).isEmpty();
|
||||
assertThat(service.snapshot("document", "1")).hasSize(1);
|
||||
assertThat(service.snapshot("document", "1").getFirst().userId()).isEqualTo("alice");
|
||||
assertThat(service.snapshot("document", "1").getFirst().fieldName()).isEqualTo("title");
|
||||
instance.getTable("document").getField("title").setIsHidden(true);
|
||||
assertThat(service.snapshot("document", "1").getFirst().fieldName()).isNull();
|
||||
String bobLease = service.openLease("document", "1");
|
||||
assertThatThrownBy(() -> service.renewLease(bobLease, "document", "1", "EDITING", "title", 1L))
|
||||
.isInstanceOf(QBadRequestException.class).hasMessageContaining("focused field");
|
||||
assertThatThrownBy(() -> service.snapshot("document", "3")).isInstanceOf(QNotFoundException.class);
|
||||
QContext.getQSession().clearSecurityKeyValues();
|
||||
QContext.getQSession().withSecurityKeyValue("tenant", "b");
|
||||
assertThat(service.snapshot("document", null)).isEmpty();
|
||||
login("alice");
|
||||
service.leave(aliceLease, "document", "1", 2L);
|
||||
assertThat(service.snapshot("document", "1")).isEmpty();
|
||||
service.renewLease(service.openLease("document", "1"), "document", "1", "VIEWING", null, 1L);
|
||||
var client = com.kingsrook.qbits.redis.RedisRuntimeService.connection(instance, "presence").client();
|
||||
// Test-only corruption of the disposable Redis server's record state.
|
||||
client.del(client.keys("*:record:*:state").iterator().next());
|
||||
assertThatThrownBy(() -> service.snapshot("document", "1"))
|
||||
.isInstanceOf(QServiceUnavailableException.class).hasMessage("Presence is temporarily unavailable. Please retry.").hasNoCause();
|
||||
redis.stop();
|
||||
assertThatThrownBy(() -> service.snapshot("document", "1"))
|
||||
.isInstanceOf(QServiceUnavailableException.class).hasMessage("Presence is temporarily unavailable. Please retry.").hasNoCause();
|
||||
assertThatThrownBy(() -> service.heartbeat("tab", "document", "3", "VIEWING", null, 1L))
|
||||
.isInstanceOf(QNotFoundException.class);
|
||||
admin();
|
||||
assertThatThrownBy(com.kingsrook.qbits.collaboration.app.CollaborationHome::presence)
|
||||
.isInstanceOf(QServiceUnavailableException.class);
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.stop();
|
||||
runtime.afterApplicationStop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Tenant identity comes from the authenticated host session, never the request.
|
||||
***************************************************************************/
|
||||
public static class TenantScope implements PresenceScopeProvider
|
||||
{
|
||||
/************************************************************************
|
||||
** Require exactly one authenticated tenant in this fixture.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public String tenantScope()
|
||||
{
|
||||
var tenants = QContext.getQSession().getSecurityKeyValues("tenant");
|
||||
if(tenants.size() != 1)
|
||||
{
|
||||
throw (new IllegalStateException("One tenant required"));
|
||||
}
|
||||
return (tenants.getFirst().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* 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.Duration;
|
||||
import com.kingsrook.qbits.collaboration.presence.RedisPresenceStore;
|
||||
import com.kingsrook.qbits.redis.RedisConnection;
|
||||
import com.kingsrook.qbits.redis.api.RedisNamespace;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import redis.clients.jedis.DefaultJedisClientConfig;
|
||||
import redis.clients.jedis.HostAndPort;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Independent clients exercise server-side ordering, expiry and shared snapshots.
|
||||
*******************************************************************************/
|
||||
class RedisPresenceStoreTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** Server-issued leases cross nodes but cannot survive departure or data loss.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void issuedLeaseRequiresLiveAuthority() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
var address = new HostAndPort(redis.getHost(), redis.getMappedPort(6379));
|
||||
var config = DefaultJedisClientConfig.builder().build();
|
||||
try(var first = new RedisConnection(address, config, 2, Duration.ofMillis(500));
|
||||
var second = new RedisConnection(address, config, 2, Duration.ofMillis(500)))
|
||||
{
|
||||
var scope = new RedisNamespace("test", "app", "issued", "presence");
|
||||
var a = new RedisPresenceStore(first.scripts(), scope, Duration.ofSeconds(45), 10);
|
||||
var b = new RedisPresenceStore(second.scripts(), scope, Duration.ofSeconds(45), 10);
|
||||
String lease = a.openLease("one", "One", "orders", "1");
|
||||
assertThat(lease).startsWith("lease:");
|
||||
assertThat(b.snapshot("orders", "1")).isEmpty();
|
||||
assertThat(b.snapshot("orders", null)).isEmpty();
|
||||
assertThatThrownBy(() -> b.renewLease("other", "Other", lease, "orders", "1", "EDITING", null, 1L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThatThrownBy(() -> b.renewLease("one", "One", lease, "orders", "2", "EDITING", null, 1L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(b.renewLease("one", "One", lease, "orders", "1", "EDITING", "status", 1L)).isTrue();
|
||||
var expiryKey = first.client().keys("*:record:*:expiry").iterator().next();
|
||||
var expiryBefore = first.client().zrangeWithScores(expiryKey, 0, -1);
|
||||
assertThat(a.renewLease("one", "One", lease, "orders", "1", "VIEWING", null, 1L)).isTrue();
|
||||
assertThat(first.client().zrangeWithScores(expiryKey, 0, -1)).isEqualTo(expiryBefore);
|
||||
assertThat(a.snapshot("orders", "1")).hasSize(1);
|
||||
assertThat(a.snapshot("orders", "1").getFirst().mode()).isEqualTo("EDITING");
|
||||
assertThat(a.snapshot("orders", null)).allSatisfy(viewer -> assertThat(viewer.fieldName()).isNull());
|
||||
a.leave("one", lease, "orders", "1", 3L);
|
||||
assertThatThrownBy(() -> b.renewLease("one", "One", lease, "orders", "1", "EDITING", null, 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThatThrownBy(() -> b.renewLease("one", "One", lease, "orders", "1", "EDITING", null, 4L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
|
||||
String replacement = b.openLease("one", "One", "orders", "1");
|
||||
assertThat(replacement).isNotEqualTo(lease);
|
||||
assertThat(a.renewLease("one", "One", replacement, "orders", "1", "VIEWING", null, 1L)).isTrue();
|
||||
first.client().flushDB();
|
||||
assertThatThrownBy(() -> b.renewLease("one", "One", replacement, "orders", "1", "VIEWING", null, 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThatThrownBy(() -> b.heartbeat("one", "One", replacement, "orders", "1", "VIEWING", null, 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(first.client().dbSize()).isZero();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Abandoned reservations consume bounded capacity and expire invisibly.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void issuedLeaseExpiryDoesNotRecreateMembership() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
try(var connection = new RedisConnection(new HostAndPort(redis.getHost(), redis.getMappedPort(6379)),
|
||||
DefaultJedisClientConfig.builder().build(), 2, Duration.ofMillis(500)))
|
||||
{
|
||||
var store = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", "issued-expiry", "presence"), Duration.ofMillis(150), 1);
|
||||
String pending = store.openLease("one", "One", "orders", "1");
|
||||
assertThat(store.openLease("two", "Two", "orders", "2")).isNull();
|
||||
assertThat(store.snapshot("orders", null)).isEmpty();
|
||||
Thread.sleep(250);
|
||||
assertThatThrownBy(() -> store.renewLease("one", "One", pending, "orders", "1", "EDITING", null, 1L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
String active = store.openLease("two", "Two", "orders", "2");
|
||||
assertThat(store.renewLease("two", "Two", active, "orders", "2", "EDITING", null, 1L)).isTrue();
|
||||
Thread.sleep(250);
|
||||
assertThatThrownBy(() -> store.renewLease("two", "Two", active, "orders", "2", "EDITING", null, 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(store.snapshot("orders", null)).isEmpty();
|
||||
String departed = store.openLease("two", "Two", "orders", "2");
|
||||
assertThat(departed).isNotNull();
|
||||
store.renewLease("two", "Two", departed, "orders", "2", "EDITING", null, 1L);
|
||||
store.leave("two", departed, "orders", "2", 3L);
|
||||
Thread.sleep(250);
|
||||
assertThatThrownBy(() -> store.renewLease("two", "Two", departed, "orders", "2", "EDITING", null, 2L))
|
||||
.isInstanceOf(com.kingsrook.qbits.collaboration.presence.PresenceLeaseExpiredException.class);
|
||||
assertThat(store.snapshot("orders", null)).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Characterize the open recovery gap: all projections lost together look new.
|
||||
** This is evidence for #1295, not an assertion of the desired final contract.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void completeDataLossCurrentlyAcceptsDelayedHeartbeat() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
var address = new HostAndPort(redis.getHost(), redis.getMappedPort(6379));
|
||||
var config = DefaultJedisClientConfig.builder().build();
|
||||
try(var first = new RedisConnection(address, config, 2, Duration.ofMillis(500));
|
||||
var second = new RedisConnection(address, config, 2, Duration.ofMillis(500)))
|
||||
{
|
||||
var scope = new RedisNamespace("test", "app", "recovery", "presence");
|
||||
var a = new RedisPresenceStore(first.scripts(), scope, Duration.ofSeconds(45), 10);
|
||||
var b = new RedisPresenceStore(second.scripts(), scope, Duration.ofSeconds(45), 10);
|
||||
a.heartbeat("active", "Active", "active-tab", "orders", "1", "VIEWING", null, 1L);
|
||||
a.heartbeat("departed", "Departed", "old-tab", "orders", "1", "EDITING", null, 1L);
|
||||
b.leave("departed", "old-tab", "orders", "1", 3L);
|
||||
assertThat(b.snapshot("orders", "1")).extracting("userId").containsExactly("active");
|
||||
|
||||
// Only this disposable test server is cleared. Both clients stay alive.
|
||||
first.client().flushDB();
|
||||
assertThat(b.snapshot("orders", "1")).isEmpty();
|
||||
assertThat(a.heartbeat("departed", "Departed", "old-tab", "orders", "1", "EDITING", null, 2L)).isTrue();
|
||||
assertThat(b.snapshot("orders", "1")).extracting("userId").containsExactly("departed");
|
||||
assertThat(b.snapshot("orders", null)).extracting("userId").containsExactly("departed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Characterize the separate ordering gap after a departure marker expires.
|
||||
** A newer room member keeps both projections alive throughout this test.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void expiredDepartureCurrentlyAcceptsDelayedHeartbeat() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
try(var connection = new RedisConnection(new HostAndPort(redis.getHost(), redis.getMappedPort(6379)),
|
||||
DefaultJedisClientConfig.builder().build(), 2, Duration.ofMillis(500)))
|
||||
{
|
||||
var store = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", "late", "presence"), Duration.ofMillis(150), 10);
|
||||
store.heartbeat("departed", "Departed", "tab", "orders", "1", "EDITING", null, 1L);
|
||||
store.leave("departed", "tab", "orders", "1", 3L);
|
||||
store.heartbeat("departed", "Departed", "tab", "orders", "1", "EDITING", null, 2L);
|
||||
assertThat(store.snapshot("orders", "1")).isEmpty();
|
||||
|
||||
// Longer-lived peer proves this does not depend on losing entire keys.
|
||||
var peer = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", "late", "presence"), Duration.ofSeconds(45), 10);
|
||||
peer.heartbeat("active", "Active", "peer-tab", "orders", "1", "VIEWING", null, 1L);
|
||||
Thread.sleep(250);
|
||||
assertThat(peer.snapshot("orders", "1")).extracting("userId").containsExactly("active");
|
||||
store.heartbeat("departed", "Departed", "tab", "orders", "1", "EDITING", null, 2L);
|
||||
assertThat(peer.snapshot("orders", "1")).extracting("userId").containsExactly("active", "departed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Six people share one record, with two editing; tabs deduplicate by user.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void sharedRoomOrderingIsolationAndExpiry() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
var address = new HostAndPort(redis.getHost(), redis.getMappedPort(6379));
|
||||
var config = DefaultJedisClientConfig.builder().connectionTimeoutMillis(500).socketTimeoutMillis(500).build();
|
||||
try(var first = new RedisConnection(address, config, 4, Duration.ofMillis(500));
|
||||
var second = new RedisConnection(address, config, 4, Duration.ofMillis(500)))
|
||||
{
|
||||
var scope = new RedisNamespace("test", "app", "tenant-a", "presence");
|
||||
var a = new RedisPresenceStore(first.scripts(), scope, Duration.ofSeconds(45), 100);
|
||||
var b = new RedisPresenceStore(second.scripts(), scope, Duration.ofSeconds(45), 100);
|
||||
for(int i = 0; i < 6; i++)
|
||||
{
|
||||
assertThat((i % 2 == 0 ? a : b).heartbeat("user-" + i, "Person " + i, "tab-" + i, "orders", "123",
|
||||
i < 2 ? "EDITING" : "VIEWING", i < 2 ? "status" : null, 1L)).isTrue();
|
||||
}
|
||||
assertThat(b.snapshot("orders", "123")).hasSize(6).isEqualTo(a.snapshot("orders", "123"));
|
||||
assertThat(b.snapshot("orders", "123").stream().filter(viewer -> viewer.mode().equals("EDITING"))).hasSize(2);
|
||||
assertThat(b.snapshot("orders", null)).hasSize(6).allSatisfy(viewer -> assertThat(viewer.fieldName()).isNull());
|
||||
assertThat(a.heartbeat("user-0", "Person 0", "extra-tab", "orders", "123", "VIEWING", null, 1L)).isTrue();
|
||||
assertThat(b.snapshot("orders", "123")).hasSize(6);
|
||||
assertThat(b.snapshot("orders", "123").getFirst().mode()).isEqualTo("EDITING");
|
||||
assertThat(b.leave("user-0", "tab-0", "orders", "123", 3L)).isTrue();
|
||||
assertThat(a.heartbeat("user-0", "Person 0", "tab-0", "orders", "123", "EDITING", "status", 2L)).isTrue();
|
||||
assertThat(a.snapshot("orders", "123").getFirst().mode()).isEqualTo("VIEWING");
|
||||
assertThat(a.heartbeat("user-0", "Person 0", "tab-0", "orders", "123", "EDITING", "status", 3L)).isTrue();
|
||||
assertThat(a.snapshot("orders", "123").getFirst().mode()).isEqualTo("VIEWING");
|
||||
assertThat(a.heartbeat("user-0", "Person 0", "tab-0", "orders", "123", "EDITING", "status", 4L)).isTrue();
|
||||
assertThat(b.snapshot("orders", "123").getFirst().mode()).isEqualTo("EDITING");
|
||||
var tenantB = new RedisPresenceStore(second.scripts(), new RedisNamespace("test", "app", "tenant-b", "presence"), Duration.ofSeconds(45), 100);
|
||||
assertThat(tenantB.snapshot("orders", "123")).isEmpty();
|
||||
assertThat(a.snapshot("orders", "124")).isEmpty();
|
||||
|
||||
try(var writers = java.util.concurrent.Executors.newFixedThreadPool(4))
|
||||
{
|
||||
var writes = new java.util.ArrayList<java.util.concurrent.Future<Boolean>>();
|
||||
for(long sequence = 10; sequence < 50; sequence++)
|
||||
{
|
||||
final long requestSequence = sequence;
|
||||
writes.add(writers.submit(() -> (requestSequence % 2 == 0 ? a : b).heartbeat("racing-user", "Racing", "racing-tab",
|
||||
"orders", "124", requestSequence == 49 ? "EDITING" : "VIEWING", null, requestSequence)));
|
||||
}
|
||||
for(var write : writes)
|
||||
{
|
||||
assertThat(write.get(10, java.util.concurrent.TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
}
|
||||
assertThat(a.snapshot("orders", "124")).hasSize(1);
|
||||
assertThat(a.snapshot("orders", "124").getFirst().mode()).isEqualTo("EDITING");
|
||||
assertThat(b.leave("racing-user", "racing-tab", "orders", "124", 50L)).isTrue();
|
||||
assertThat(a.heartbeat("racing-user", "Racing", "racing-tab", "orders", "124", "EDITING", null, 49L)).isTrue();
|
||||
assertThat(a.snapshot("orders", "124")).isEmpty();
|
||||
|
||||
var shortLease = new RedisPresenceStore(first.scripts(), new RedisNamespace("test", "app", "short", "presence"), Duration.ofMillis(150), 10);
|
||||
assertThat(shortLease.heartbeat("user", "Person", "tab", "orders", "123", "EDITING", "status", 1L)).isTrue();
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
|
||||
while(!shortLease.snapshot("orders", "123").isEmpty() && System.nanoTime() < deadline)
|
||||
{
|
||||
Thread.sleep(20);
|
||||
}
|
||||
assertThat(shortLease.snapshot("orders", "123")).isEmpty();
|
||||
assertThat(shortLease.snapshot("orders", null)).isEmpty();
|
||||
redis.execInContainer("redis-cli", "SCRIPT", "FLUSH");
|
||||
assertThat(b.snapshot("orders", "123")).hasSize(6);
|
||||
redis.stop();
|
||||
assertThatThrownBy(() -> a.snapshot("orders", "123")).isInstanceOf(redis.clients.jedis.exceptions.JedisException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A full table cannot leave a half-written record membership behind.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void capacityChecksAreAtomic() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
try(var connection = new RedisConnection(new HostAndPort(redis.getHost(), redis.getMappedPort(6379)),
|
||||
DefaultJedisClientConfig.builder().build(), 2, Duration.ofMillis(500)))
|
||||
{
|
||||
var store = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", "capacity", "presence"), Duration.ofSeconds(45), 1);
|
||||
assertThat(store.heartbeat("one", "One", "tab", "orders", "1", "VIEWING", null, 1L)).isTrue();
|
||||
assertThat(store.heartbeat("two", "Two", "tab", "orders", "2", "VIEWING", null, 1L)).isFalse();
|
||||
assertThat(store.snapshot("orders", "2")).isEmpty();
|
||||
assertThat(store.snapshot("orders", null)).hasSize(1);
|
||||
assertThat(store.leave("one", "tab", "orders", "1", 2L)).isTrue();
|
||||
assertThat(store.snapshot("orders", null)).isEmpty();
|
||||
assertThat(store.heartbeat("two", "Two", "tab", "orders", "2", "VIEWING", null, 1L)).isFalse();
|
||||
assertThatThrownBy(() -> store.heartbeat("one", "One", "tab", "orders", "1", "INVALID", null, 3L)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Losing one half of a room must not report empty or partially renew a lease.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void partialRoomLossIsUnavailable() throws Exception
|
||||
{
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
try(var connection = new RedisConnection(new HostAndPort(redis.getHost(), redis.getMappedPort(6379)),
|
||||
DefaultJedisClientConfig.builder().build(), 2, Duration.ofMillis(500)))
|
||||
{
|
||||
for(String suffix : java.util.List.of("state", "expiry"))
|
||||
{
|
||||
var store = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", suffix, "presence"), Duration.ofSeconds(45), 10);
|
||||
assertThat(store.heartbeat("one", "One", "tab", "orders", "1", "VIEWING", null, 1L)).isTrue();
|
||||
// KEYS is confined to this disposable test server, never the application path.
|
||||
String recordKey = connection.client().keys("*:record:*:" + suffix).iterator().next();
|
||||
String tableKey = recordKey.substring(0, recordKey.indexOf(":record:")) + ":table:state";
|
||||
var before = connection.client().hgetAll(tableKey);
|
||||
connection.client().del(recordKey);
|
||||
assertThatThrownBy(() -> store.snapshot("orders", "1"))
|
||||
.isInstanceOf(redis.clients.jedis.exceptions.JedisException.class).hasMessageContaining("incomplete");
|
||||
assertThatThrownBy(() -> store.heartbeat("one", "One", "tab", "orders", "1", "EDITING", "status", 2L))
|
||||
.isInstanceOf(redis.clients.jedis.exceptions.JedisException.class).hasMessageContaining("incomplete");
|
||||
assertThat(connection.client().hgetAll(tableKey)).isEqualTo(before);
|
||||
connection.client().flushDB();
|
||||
}
|
||||
for(String room : java.util.List.of("record", "table"))
|
||||
{
|
||||
var store = new RedisPresenceStore(connection.scripts(), new RedisNamespace("test", "app", room, "presence"), Duration.ofSeconds(45), 10);
|
||||
store.heartbeat("one", "One", "tab", "orders", "1", "VIEWING", null, 1L);
|
||||
store.leave("one", "tab", "orders", "1", 3L);
|
||||
var lostKeys = connection.client().keys(room.equals("record") ? "*:record:*" : "*:table:*");
|
||||
connection.client().del(lostKeys.toArray(String[]::new));
|
||||
assertThatThrownBy(() -> store.snapshot("orders", "1"))
|
||||
.isInstanceOf(redis.clients.jedis.exceptions.JedisException.class).hasMessageContaining("incomplete");
|
||||
assertThatThrownBy(() -> store.heartbeat("one", "One", "tab", "orders", "1", "EDITING", null, 2L))
|
||||
.isInstanceOf(redis.clients.jedis.exceptions.JedisException.class).hasMessageContaining("incomplete");
|
||||
assertThat(connection.client().keys(room.equals("record") ? "*:record:*" : "*:table:*")).isEmpty();
|
||||
connection.client().flushDB();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,11 +389,16 @@ public class SharingWorkTest extends PersonalServiceFixture
|
||||
@Test
|
||||
void assignmentsUpsertAndMyRecordsRespectParentLocks() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> assignments.assignRoles("document", 1, null, null, java.util.Set.of())).hasMessageContaining("Supply ownerUserId");
|
||||
assertThatThrownBy(() -> assignments.assign("document", 1, "eve", null)).hasMessageContaining("can't open");
|
||||
QRecord first = assignments.assign("document", 1, "alice", "bob");
|
||||
QRecord again = assignments.assign("document", "01", "alice", "bob");
|
||||
assertThat(again.getValue("id")).isEqualTo(first.getValue("id"));
|
||||
assertThat(recorder.notifications()).hasSize(1);
|
||||
QRecord ownerOnly = assignments.assignRoles("document", 1, null, null, java.util.Set.of("ownerUserId"));
|
||||
assertThat(ownerOnly.getValueString("ownerUserId")).isNull();
|
||||
assertThat(ownerOnly.getValueString("assigneeUserId")).isEqualTo("bob");
|
||||
assignments.assignRoles("document", 1, "alice", null, java.util.Set.of("ownerUserId"));
|
||||
assignments.assign("document", 2, null, "alice");
|
||||
assertThat(assignments.myRecords(null, 1).get("document").count()).isEqualTo(2);
|
||||
assertThat(assignments.myRecords("document", 1).get("document").records()).hasSize(1);
|
||||
@@ -404,6 +409,21 @@ public class SharingWorkTest extends PersonalServiceFixture
|
||||
assertThat(instance.getTable(CollaborationTables.RECORD_ASSIGNMENT).getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The fallback API cannot create a competing owner for native ownership metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fallbackAssignmentsRejectNativeOwnership() throws Exception
|
||||
{
|
||||
var metadata = com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document"));
|
||||
metadata.withOwnership(new com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordOwnership().withOwnerFieldName("title"));
|
||||
com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay.refresh(instance);
|
||||
assertThatThrownBy(() -> assignments.assignRoles("document", 1, "alice", null, java.util.Set.of("ownerUserId")))
|
||||
.hasMessageContaining("native ownership");
|
||||
assertThat(assignments.get("document", 1)).isEmpty();
|
||||
assertThat(assignments.assignRoles("document", 1, null, "bob", java.util.Set.of("assigneeUserId")).getValueString("assigneeUserId")).isEqualTo("bob");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** My Work Summarizes Only Visible Work And Read Mentions.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -32,6 +32,10 @@ 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;
|
||||
@@ -182,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.
|
||||
*******************************************************************************/
|
||||
|
||||
+14
@@ -102,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
|
||||
{
|
||||
|
||||
@@ -310,6 +310,9 @@ public class CollaborationDevServer extends AbstractQQQApplication
|
||||
{
|
||||
return (new QTableMetaData().withName(name).withLabel(label).withIcon(new QIcon(icon)).withBackendName("memory")
|
||||
.withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME)
|
||||
.withBehavior(com.kingsrook.qqq.backend.core.model.metadata.fields.DynamicDefaultValueBehavior.MODIFY_DATE))
|
||||
.withSection(new QFieldSection("audit", new QIcon("history"), Tier.T3, List.of("modifyDate")).withLabel("Updated"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)));
|
||||
}
|
||||
|
||||
+129
@@ -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.devserver;
|
||||
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceScopeProvider;
|
||||
import com.kingsrook.qbits.collaboration.presence.RedisPresenceConfig;
|
||||
import com.kingsrook.qbits.redis.RedisConnectionConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitProducer;
|
||||
import com.kingsrook.qbits.redis.RedisRealtimeRuntimeService;
|
||||
import com.kingsrook.qbits.redis.RedisRuntimeService;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
|
||||
/*******************************************************************************
|
||||
** Loopback-only browser acceptance node. Run separate JVMs against one disposable
|
||||
** Redis endpoint. Record data is independently seeded, not a shared database.
|
||||
*******************************************************************************/
|
||||
public class SharedPresenceDevServer
|
||||
{
|
||||
/***************************************************************************
|
||||
** Arguments are HTTP port and disposable local Redis port. Never deploy this
|
||||
** fixture: it uses the demo's mock authentication and plaintext loopback Redis.
|
||||
***************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
if(args.length != 2)
|
||||
{
|
||||
throw (new IllegalArgumentException("Expected HTTP port and local Redis port"));
|
||||
}
|
||||
int port = Integer.parseInt(args[0]);
|
||||
int redisPort = Integer.parseInt(args[1]);
|
||||
var instance = CollaborationDevServer.buildInstance(port);
|
||||
CollaborationDevServer.seed(instance);
|
||||
instance.withInstanceDefaultAuthentication(new com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData()
|
||||
.withName("mock").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(AcceptanceSessions.class)));
|
||||
PresenceRegistry.close(instance);
|
||||
RealtimeBus.close(instance);
|
||||
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("presence",
|
||||
new RedisConnectionConfig().withHost("127.0.0.1").withPort(redisPort).withTls(false))
|
||||
.withRealtime("presence", "acceptance", "collaboration")).produce(instance);
|
||||
instance.getQBits().values().stream().map(bit -> bit.getConfig())
|
||||
.filter(CollaborationQBitConfig.class::isInstance).map(CollaborationQBitConfig.class::cast)
|
||||
.findFirst().orElseThrow().withRedisPresence(new RedisPresenceConfig().withConnectionName("presence")
|
||||
.withDeployment("acceptance").withApplication("collaboration")
|
||||
.withScopeProvider(new QCodeReference(SingleTenant.class)));
|
||||
var redis = new RedisRuntimeService();
|
||||
var transport = new RedisRealtimeRuntimeService();
|
||||
new com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider().defineAll(instance, "memory", null);
|
||||
var health = new com.kingsrook.qqq.backend.core.health.HealthRunner(instance, "acceptance-" + port, java.time.Clock.systemUTC());
|
||||
var server = CollaborationDevServer.server(instance, port, false);
|
||||
try
|
||||
{
|
||||
redis.start(instance);
|
||||
transport.start(instance);
|
||||
health.start();
|
||||
server.start();
|
||||
System.out.println("SHARED_PRESENCE_READY " + port);
|
||||
Thread.currentThread().join();
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop();
|
||||
health.stop();
|
||||
transport.stop();
|
||||
PresenceRegistry.close(instance);
|
||||
RealtimeBus.close(instance);
|
||||
redis.stop();
|
||||
redis.afterApplicationStop();
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit single-tenant fixture; no scope comes from browser request input.
|
||||
***************************************************************************/
|
||||
public static class SingleTenant implements PresenceScopeProvider
|
||||
{
|
||||
/************************************************************************
|
||||
** The two acceptance nodes share this configured tenant.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public String tenantScope()
|
||||
{
|
||||
return ("acceptance");
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Grant Redis dashboard access only to the fixture's existing administrator.
|
||||
***************************************************************************/
|
||||
public static class AcceptanceSessions extends CollaborationDevServer.DemoSessions
|
||||
{
|
||||
/************************************************************************
|
||||
** Keep normal demo identity/grants and add the service-view permission.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance,
|
||||
com.kingsrook.qqq.backend.core.model.session.QSession target, java.util.Map<String, Object> context)
|
||||
{
|
||||
super.customizeSession(instance, target, context);
|
||||
if("admin".equals(target.getUser().getIdReference()))
|
||||
{
|
||||
target.withPermissions("redisView.hasAccess", "health.read");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-4
@@ -43,6 +43,8 @@ import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderI
|
||||
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;
|
||||
@@ -159,7 +161,7 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
|
||||
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 a record in Documents");
|
||||
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");
|
||||
});
|
||||
@@ -175,9 +177,30 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
** own templates and key kinds, including follow digests.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void oneMessagePerRecipientAndKinds() throws Exception
|
||||
@@ -188,7 +211,7 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
|
||||
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");
|
||||
.containsExactlyInAnyOrder("collaboration:share:3:bob", "collaboration:share:3:eve", "collaboration:task:9:remindAt:bob", "collaboration:follow:1:bob");
|
||||
assertThat(messages()).allSatisfy(m -> assertThat(m.getValueString("toAddresses")).doesNotContain(","));
|
||||
assertThat(EmailApi.findMessagesByIdempotencyKeyPrefix("collaboration:", 10)).extracting(EmailApiMessage::templateName)
|
||||
.contains("collaboration_share", "collaboration_task_reminder");
|
||||
@@ -205,7 +228,11 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
|
||||
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"));
|
||||
boolean[] receipt = {false};
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "2"), () -> receipt[0] = true);
|
||||
assertThat(receipt[0]).isFalse();
|
||||
Notifications.send(notification("eve", CollaborationNotification.Category.MENTION, "1"), () -> receipt[0] = true);
|
||||
assertThat(receipt[0]).isTrue();
|
||||
assertThat(messages()).singleElement().satisfies(m -> assertThat(m.getValueString("toAddresses")).contains("other@example.org"));
|
||||
}
|
||||
|
||||
|
||||
@@ -52,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;
|
||||
@@ -114,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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Configuration status="WARN">
|
||||
<Appenders>
|
||||
<Console name="Console" target="SYSTEM_ERR">
|
||||
<PatternLayout pattern="%level %logger{1}: %msg%n"/>
|
||||
</Console>
|
||||
</Appenders>
|
||||
<Loggers>
|
||||
<Root level="WARN"><AppenderRef ref="Console"/></Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
Reference in new issue
Block a user