Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbce2e5d6e
|
||
|
|
70f6184a7a
|
||
|
|
16bd4f768f
|
||
|
|
04d04adc6e
|
||
|
|
de922d7e86
|
||
|
|
c6b077321c
|
||
|
|
2837f4152a
|
||
|
|
f0f1db7642
|
||
|
|
c8150bc9cf
|
||
|
|
1c6ae14f59
|
||
|
|
9248f3f32c | ||
|
|
468dc42751 | ||
|
|
73681b3a2e
|
||
|
|
483dc271fe
|
||
|
|
813894f8c6
|
||
|
|
ef625ca300 | ||
|
|
6060b737c5
|
||
|
|
024cfc21a9
|
||
|
|
63245b14e0
|
||
|
|
fb7c6316bf
|
||
|
|
9fbe20c9d6
|
||
|
|
1c05422901
|
||
|
|
89e77b67b9
|
||
|
|
9c51710f09
|
||
|
|
a0f9a172d0
|
||
|
|
7d61f29e92
|
||
|
|
6e60585af1
|
||
|
|
594e755da4
|
||
|
|
4679821cb4
|
||
|
|
f8337989e8
|
||
|
|
6cf98bf1fd | ||
|
|
0ae1e62b7b | ||
|
|
47cbc7c72d | ||
|
|
1ae6508793 | ||
|
|
03407ae79a | ||
|
|
9d6767e731 | ||
|
|
aa6a4a3b58 | ||
|
|
d9a46afbf9 | ||
|
|
b5308a8bab | ||
|
|
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 | ||
|
|
d5adcec567
|
||
|
|
83b253a94f | ||
|
|
4eb5b70ade | ||
|
|
da932fb309
|
||
|
|
66189ec110 | ||
|
|
23d3365437
|
||
|
|
f0b8fc9141
|
||
|
|
6cd78a803e | ||
|
|
b4032baf70 | ||
|
|
d2ad22eb97 | ||
|
|
66b5bf2bea | ||
|
|
d828d1677d
|
||
|
|
ef2ee45b5a
|
||
|
|
03669391d0
|
||
|
|
7e63314413
|
||
|
|
68fe96c22f
|
||
|
|
c9954efef4
|
||
|
|
e696d9edaf
|
||
|
|
42cf51325a
|
||
|
|
95506ed706
|
||
|
|
bc992fd5f8
|
||
|
|
5f56bf2853
|
||
|
|
a9baecd293
|
||
|
|
7b7c921079
|
||
|
|
08498dd1db
|
||
|
|
77d005efd3
|
||
|
|
05b7fcdc08 | ||
|
|
9ba6e41377
|
||
|
|
38a95aeee0 | ||
|
|
ab6e8442be
|
||
|
|
88bff675e9
|
||
|
|
0b19f956a2 | ||
|
|
22c9ec22fa
|
||
|
|
003384abd4 | ||
|
|
be1982a0b1
|
||
|
|
696f4f48ce
|
||
|
|
76e4944919 | ||
|
|
711baede51
|
||
|
|
4898e78c5d | ||
|
|
1e750b9dc6 |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -42,7 +42,7 @@ 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.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.1.1`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-SNAPSHOT`; build the matching Core integration branch first (see `docs/CORE-DEPENDENCY.md`). Public RC.21 lacks required rollback callbacks.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
+83
-2
@@ -6,10 +6,92 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
### Added
|
||||
|
||||
- Attachment lifecycle SPI (`AttachmentLifecyclePolicy`, `CollaborationQBitConfig.withAttachmentLifecycle`) for retention, registration and mandatory access events; durable upload journal (`collaboration_upload_journal`, H2/MySQL/PostgreSQL), upload recovery and existing-attachment adoption processes, durable cleanup queue with operator settlement, verified/authenticated delivery (`AttachmentDownload`, `AttachmentContentIntegrityException`), media policy, aggregate guard and bounded attachment pagination. See `docs/ATTACHMENT-LIFECYCLE.md`, `docs/ATTACHMENT-RECOVERY.md`, `docs/RETAINED-UPLOAD-JOURNAL.md`, `docs/ATTACHMENT-PAGINATION.md`.
|
||||
- Requires a core whose `QBackendTransaction` provides `supportsRollbackCallbacks()` and `addAfterRollbackCallback(Runnable)` (not in 4.1.0-RC.21); see `docs/CORE-DEPENDENCY.md`.
|
||||
- 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.2.0, additive (#54): presence viewers carry `connections` (live leases merged into the viewer), snapshots carry `leaseCount`, and `GET /presence?perConnection=true` adds `leases`, one viewer per connection with an opaque `connectionId`, `mode`, `fieldName`, `sessionId` and `since`. `presence.changed` events always carry `leaseCount` and `leases`. Leases keep an opaque HMAC-SHA256 session id (secret `QQQ_PRESENCE_SESSION_SECRET` or random per process), never the session uuid, which is a bearer credential. `PresenceRegistry.connectionCounts()` counts leases next to the unchanged user `counts()`; the Who's here card shows both.
|
||||
- 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
|
||||
|
||||
- Favorites and recents accept a table whose custom permission checker tells a read from a write by the type of the action input. `TargetResolver` asked whether a table is readable with an untyped `AbstractActionInput`; such a checker refuses an input it does not recognise, so `POST /recents` (which the UI sends on every list and record screen) and starring answered `404 Not found` for people the checker lets read the table. The read is now asked as the query it stands for, as `ParentRecordAccess` already asks with a get. Who may read a table is unchanged.
|
||||
- Reads of a table that has no tag assignment no longer query `collaboration_record_tag`. Core selects the Tags field for every query or get that names no fields, so each of them (including per-record gets and the stored-record read before an update) ran one assignment statement whether or not the table had ever been tagged: 600 gets of an untagged table ran 600 statements, now none. Each node remembers which tables have no assignment (`TaggedTables`, core `Memoization` registered with `ClusterCaches`) for `CollaborationQBitConfig.withTagLookupCacheSeconds` (default 30, 0 disables); writing an assignment clears it on the node and through the ClusterBus. Tables with assignments are read as before, one lookup per 500 parents. No API or schema change. See "What the Tags field costs a read" in the README.
|
||||
- Browsing one of the qbit's child tables (comments, tasks, record tags, attachments and the other parent-referencing tables) reads each distinct parent once per result page to authorize its rows, instead of once per row: 61 tasks on two parents ran 61 parent reads, now 2. The check itself and its outcome are unchanged.
|
||||
- The retained upload journal's PostgreSQL 17 qualification accepts Amazon Aurora PostgreSQL, which reports `full_page_writes=off` and does not let a deployment change it. `off` is accepted only when the settings query detects Aurora (`to_regprocedure('aurora_version()') IS NOT NULL`); stock PostgreSQL with `full_page_writes=off` is still refused, and every other requirement is unchanged on Aurora. See `docs/RETAINED-UPLOAD-JOURNAL.md`.
|
||||
- Bounded STRING custom fields carry QQQ's native `ValueTooLongBehavior.ERROR`, so a write longer than `maxLength` is rejected with a field error and the stored value is preserved, instead of passing through unchecked (the QQQ default `PASS_THROUGH`).
|
||||
- Persisted help content is reloaded after each committed runtime metadata refresh, so sections and fields created by publishing a custom-field definition receive their stored `helpContent` rows without a restart.
|
||||
- QBit metadata reports the Maven artifact version from a filtered resource, including release versions.
|
||||
- `presence.changed` events and presence snapshots always carry `viewers`, sending `[]` once the last viewer leaves, as the schema requires (#52).
|
||||
|
||||
- Timeline audit summaries read "Edited by Alice Morgan: Title, Total" or "Created by Alice Morgan" instead of "Record was Edited"; detail rows are unchanged.
|
||||
- The demo server chooses its user per browser session (`?user=bob`, remembered in a cookie) instead of once per JVM, and sends the person's name to the frontend so it no longer shows Anonymous.
|
||||
- The share recipient search and mention candidates no longer offer the caller to themself.
|
||||
- The demo server configures `frontendBaseUrl`, so record links are absolute URLs (the API already returned absolute links once it is set).
|
||||
- Tag filters accept tag ids as any JSON number or numeric string; other strings remain names.
|
||||
- Collaboration home rebuilt from standard KPI and table widgets with empty states; the demo Admin parent app is reachable so Collaboration appears in the app tree.
|
||||
|
||||
- Dependent possible values for comment parents (thread root/replies), feature-setting scope names (apps/eligible tables/instance), and comment field anchors (session-visible fields), with RC.5 action-level tests.
|
||||
|
||||
### Added
|
||||
|
||||
- Phase f custom-field designer: audited definitions, secured EAV children, runtime JSON-path fields, classification, validation, lifecycle actions, PostgreSQL probes/index jobs and migration DDL. Read-only EAV filters and scalar multi-select limitations are documented.
|
||||
- Phase g presence: typed POST/DELETE/GET endpoints, 45-second leases, tab merging, a 50,000-entry cap, internal bus replication, record/table events, summaries and the admin-only Who's here card.
|
||||
- Designer, HTTP, two-pod, clock and JDBC-boundary tests; regenerated contract and existing Checkstyle/JaCoCo gates retained.
|
||||
|
||||
- Collaboration UI API extension `1.0.0`: 45 typed operations for favorites, recents, attachments, tags, comments, mentions, follow, timeline, people, sharing, access requests, tasks, assignment, My work and presence under `/qqq/v1/qbit/collaboration`, enabled by default and disabled with `withUiApi(false)`.
|
||||
- Real multipart uploads; download/preview return five-minute `{url}` responses (clients follow url) because the framework cannot describe a redirect Location header. Configurable attachment storage adapter supports in-memory HTTP tests.
|
||||
- Versioned `ToSchema` wire types, enum values, semantic parameter descriptions, generated OpenAPI fragment and drift check; real HTTP tests cover all operations, paging, parent visibility, permissions, validation and storage failures.
|
||||
- Shared HTTP page bounds of 1-100 (default 20); comments/timeline keyset cursors and offset cursors for complete-list services. People/vocabulary remain bounded searches, while recents/mention candidates retain their 50-item service windows.
|
||||
- Client validation now uses QQQ bad-request exceptions, preserving standard 400/403/404 mapping and identical missing/invisible-parent errors. Active-person lookup added to the directory service.
|
||||
|
||||
### Changed
|
||||
|
||||
- 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 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`.
|
||||
|
||||
### Previously added
|
||||
- Phase-h links, share previews, recipient-safe notifications and SHARED followers; access requests with requester/admin locks, duplicate OPEN handling, optional permission-holder directory and streamed Mark granted/Decline outcomes.
|
||||
- Phase-i tasks with assignee validation, injectable-clock reminder/overdue receipts, complete/reopen/cancel, due-date cursor paging and streamed task actions.
|
||||
- Qbit owner/assignee services, configured Assign to/Take ownership bulk actions, My work summaries and mention read receipts.
|
||||
- Four audited tables, task-title search metadata, work/share home cards, committed parent-delete cleanup, PostgreSQL tables/indexes and a mention readAt migration.
|
||||
- Acceptance tests for security, notifications, request existence neutrality, actual streamed actions, reminders, summaries, cleanup and schema. Existing JaCoCo gates retained; UI API endpoints remain separate.
|
||||
|
||||
- Phase-d tag vocabulary and record assignments on QQQ 4.1.0-RC.5; normalized names, FREE/APPROVED_ONLY policy resolved from system-read scoped feature settings, explicit global/manage permissions, parent-checked idempotent assignment, usage counts and TAGGED/UNTAGGED activity without follower notifications.
|
||||
- Tags navigation, secured Record Tags child list, FIELD audit, label search, approved/scoped table PVS, streamed Approve/Merge/Delete and configured host Add/Remove actions, parent cleanup and real Tags home metrics. Bulk multi-select intent is exposed in picker metadata for the separate Next UI implementation.
|
||||
- Standalone `TagFilterTranslator` for IN/CONTAINS_ALL/NOT_IN/IS_BLANK/IS_NOT_BLANK with a 10,000 matching-ID cap. Filter/quick-search integration lands after QRun-IO/qqq#1140 is released.
|
||||
- Tag service, security, vocabulary, settings, schema, cap and streamed Review/Result tests; existing JaCoCo gates retained.
|
||||
|
||||
- Phase-e comments/mentions, one-level threads, edit revisions, author/admin soft deletion, resolve/reopen, ranked mention candidates and keyset pagination on QQQ 4.1.0-RC.5.
|
||||
- Comments, Comment History, Mentions, Followers, Activity and durable Follow Digests tables, child-list widgets, service-only writes, shared parent-table query restrictions, schema indexes and real home counts.
|
||||
- Lazy people-directory upserts with ten-minute throttling from comments/follow/favorites/recents/attachments entry points; bounded active-name/email search.
|
||||
- Configurable notifier and recipient-session stand-ins, default no-op and recording transports, conservative unknown-access handling and RC.5 after-commit delivery with fresh access checks.
|
||||
- Follow preferences, generic streamed ETL Follow/Unfollow actions, configured RC.5 change listeners and a hidden minute scheduler for five-minute digests.
|
||||
- Timeline merging enforced core audit reads with collaboration activity, field-visibility filtering and tuple cursors. Attachments now record FILE_ADDED/FILE_REMOVED activity.
|
||||
- Memory-backed tests for recipient access, transactions, moderation, pagination, real ETL Review/Result, digest batching, directory ranking, schema and audit timeline security. Existing JaCoCo gates retained.
|
||||
|
||||
- Phase-c attachments against QQQ 4.1.0-RC.5 and object storage 0.1.0-RC.2: parent-checked upload/list/download/rename/describe/delete service, bounded streaming, SHA-256 and configurable byte/extension limits.
|
||||
- Visible `collaboration_attachment` table, FIELD auditing, file-name/description search fields, virtual presigned FILE_PREVIEW/FILE_DOWNLOAD adornments, PostgreSQL BIGINT size and parent index, and an approximate count/bytes home card.
|
||||
- Temporary `ParentRecordAccess`, readable-table query restriction through RC.5's permission hook and parent post-filtering. Panel calls do not require the admin browse grant; direct attachment table writes are service-managed.
|
||||
- Streamed ETL **Delete attachments** Review/Result record action and retryable `AttachmentCleanup.onParentsDeleted` callback. No Attach files table action; endpoint integration awaits #1128 and cleanup registration awaits core.
|
||||
- Streamed ETL **Delete attachments** Review/Result record action and retryable `AttachmentCleanup.onParentsDeleted` callback. No Attach files table action; uploads now use the versioned UI API and cleanup registration awaits core.
|
||||
- Memory-backed attachment/security/process tests. Thumbnails use FILE_PREVIEW; `thumbnailKey` remains nullable/unused because object storage has no generator.
|
||||
|
||||
- Repository scaffold; feature settings and people tables.
|
||||
@@ -18,4 +100,3 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
- `FavoritesService` and `FavoriteEntry`: idempotent star/unstar, reorder/folders, refreshed labels, batched record visibility checks, unavailable-target redaction and Next UI paths.
|
||||
- `RecentsService`: persisted one-minute throttling, visible newest-first lists (up to 50), and hidden daily `collaborationTrimRecents` scheduling with configurable per-user retention (default 200).
|
||||
- Home row/distinct-user summaries, PostgreSQL 15+ unique indexes that treat null record IDs as equal, and memory-backend security/service/retention tests.
|
||||
- UI API endpoints (§10.2) deferred to a follow-up PR after QQQ's UI API extension point ([QRun-IO/qqq#1128](https://github.com/QRun-IO/qqq/pull/1128)) is released; no unreleased core classes are required.
|
||||
@@ -1,16 +1,130 @@
|
||||
# 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. Phases d to i are planned; stored settings are not yet applied as runtime overrides and people are not yet populated automatically.
|
||||
> **Version 1.0.** Published to the KOF22 Gitea Maven registry (`com.kingsrook.qbits`). Requires QQQ Core `4.1.0-RC.22` or later. See [current status](docs/QBIT-RELEASE-STATUS.md).
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 21.
|
||||
- QQQ 4.1.0-RC.5 or newer compatible 4.1 release. The build pins RC.5.
|
||||
- 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.
|
||||
- QQQ `4.1.0-SNAPSHOT` built from the compatible Core integration branch; see [Core dependency setup](docs/CORE-DEPENDENCY.md). Middleware Javalin is a compile dependency. Public RC.21 lacks the required rollback API.
|
||||
- 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 memory, H2 and disposable PostgreSQL/MySQL containers.
|
||||
- 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.
|
||||
- Later phases and runtime overrides need the core contracts from the QQQ collaboration PRs. Favorites and recents use only APIs released in RC.5. String enum ids match the spec until the core collaboration enums are released.
|
||||
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use the same matching Core snapshot.
|
||||
|
||||
## 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 matching Core integration snapshot. No database, Docker or MinIO is needed. It binds
|
||||
to `127.0.0.1`; stopping the JVM discards edits and uploaded attachments.
|
||||
|
||||
Build the Next static export in `qqq-frontend-next`:
|
||||
|
||||
```bash
|
||||
QQQ_NEXT_OUTPUT=export pnpm build
|
||||
```
|
||||
|
||||
In PowerShell, use `$env:QQQ_NEXT_OUTPUT = 'export'; pnpm build` for that command.
|
||||
Then, from this repository, prepare the test classpath and place the export under
|
||||
a classpath directory named `next-dashboard`, following StorageDevServer's layout:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd -o -q test-compile dependency:build-classpath '-Dmdep.includeScope=test' '-Dmdep.outputFile=target/demo-classpath.txt'
|
||||
New-Item -ItemType Directory -Force target/demo-frontend/next-dashboard | Out-Null
|
||||
Copy-Item -Path ../qqq-frontend-next/out/* -Destination target/demo-frontend/next-dashboard -Recurse -Force
|
||||
$demoDeps = (Get-Content target/demo-classpath.txt -Raw).Trim()
|
||||
$demoClasspath = "target/test-classes;target/classes;$demoDeps;target/demo-frontend"
|
||||
java '-Dqqq.javalin.frontend=next' '-Dcollab.dev.port=8098' '-Dcollab.dev.user=alice' -cp $demoClasspath com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer
|
||||
```
|
||||
|
||||
The equivalent Java command is
|
||||
`java -Dqqq.javalin.frontend=next -Dcollab.dev.port=8098 -Dcollab.dev.user=alice -cp "target/test-classes;target/classes;DEPS;EXPORT" com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer`.
|
||||
`DEPS` is the generated classpath file's contents; `EXPORT` is the folder **containing**
|
||||
`next-dashboard/`, not the export itself. On macOS/Linux use `./mvnw` and `:` instead
|
||||
of `;` between classpath entries.
|
||||
|
||||
Open [the demo](http://localhost:8098/?createMockSession=true), then **Sales → People**
|
||||
or **Sales orders**. [Person 1](http://localhost:8098/app/person/1) and
|
||||
[order 1](http://localhost:8098/app/sales_order/1) have the richest collaboration panels.
|
||||
Pick the user per browser: add `user=` to the page load, for example
|
||||
`http://localhost:8098/?createMockSession=true&user=bob`. The server remembers the choice in a
|
||||
`collab_dev_user` cookie, so **open a second browser with `?user=bob` to see presence** (your
|
||||
first browser keeps its own user; browsers on the same profile share the cookie). Without a
|
||||
choice, `-Dcollab.dev.user=alice|bob|admin` (one value, not the literal list) is the default.
|
||||
The MOCK session customizer supplies stable identities, display names (the sidebar shows
|
||||
Alice Morgan, not Anonymous) and real permissions, also used by recipient access checks.
|
||||
Shared record links are absolute (`frontendBaseUrl` is `http://localhost:<port>`):
|
||||
|
||||
| User | Permissions |
|
||||
| --- | --- |
|
||||
| `alice` — Alice Morgan (default) | Full Sales access and comment, attach, tag, share, task and assign permissions |
|
||||
| `bob` — Bob Chen | People read/comment; no Sales order access |
|
||||
| `admin` — Demo Administrator | Full Sales access plus `collaboration.admin`, vocabulary/field management and Admin → Collaboration |
|
||||
|
||||
Both tables enable every record feature. People use FREE tags and host owner/assignee
|
||||
fields; orders use APPROVED_ONLY tags. The seed contains 25 fictional contacts at
|
||||
10 companies, 40 orders with deterministic amounts/dates, eight approved tags,
|
||||
two ACTIVE custom fields (Preferred contact; searchable Purchase order no.), six
|
||||
comments with three mentions and a resolved thread, three generated PNG/PDF attachments,
|
||||
follows, four tasks (one overdue and one completed), two order assignments, two shares, Bob's open order-access
|
||||
request, and six favorites/recents for Alice. Ordinary record updates create FIELD
|
||||
audit history. Task deadlines are relative to startup so the overdue example stays useful.
|
||||
|
||||
Attachment previews/downloads use real in-memory bytes and expiring local URLs.
|
||||
Notifications are recorded in memory; they do not send email. Presence is live and
|
||||
ephemeral: the current browser supplies its heartbeat; there are no fake persistent viewers.
|
||||
Two current permission edge cases remain visible in this snapshot: Bob can start a
|
||||
comment thread, but replying to another author's thread fails its activity update;
|
||||
an access request carrying a share ID for an unreadable parent returns 404. The seed
|
||||
uses Bob's own thread and a direct access request without a share ID.
|
||||
The test builds and seeds without frontend assets, validates the instance, then checks
|
||||
the version/collaboration endpoints, downloads and Bob's restricted access on a free port:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd -o -q verify
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
@@ -39,6 +153,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 |
|
||||
@@ -47,19 +170,29 @@ Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation
|
||||
| `collaboration_user` (People) | Unique session user ID, display name, email, avatar URL, last seen timestamp and active flag. Service-managed, read-only to users. |
|
||||
| `collaboration_favorite` (Favorites) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `sortOrder`, optional `folder`. Unique on user + type + name + record ID. Owner-locked, audit off. |
|
||||
| `collaboration_recent` (Recently Viewed) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `lastViewedAt`, `viewCount`. Same unique key and owner lock; audit off. |
|
||||
|
||||
| `collaboration_attachment` (Attachments) | Parent table and record ID, file metadata, SHA-256, bucket/key, optional description/thumbnail key, author and parent-deleted flag. FIELD audit; parent-based security. Preview/download URLs are virtual, never stored. |
|
||||
|
||||
All five include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold.
|
||||
| `collaboration_comment` (Comments) | Markdown body, author, root/reply reference, optional field anchor, latest thread activity, edit/resolve/delete timestamps. FIELD audit and body search. |
|
||||
| `collaboration_comment_revision` (Comment History) | Prior body, edit timestamp and editor; comment child list. |
|
||||
| `collaboration_mention` (Mentions) | Unique comment + mentioned user, recipient visibility and actual delivery timestamp; comment child list. |
|
||||
| `collaboration_follow` (Followers) | Unique parent + user, reason, change/comment notification preferences and mute timestamp. Admin browse only; Followers child list on Comments. |
|
||||
| `collaboration_activity` (Activity) | Event time, actor, type, summary and reference. Read-only admin main table. |
|
||||
| `collaboration_share` (Shares) | Recipient, sender, message, access outcome and sent/opened times. Parent security and FIELD audit. |
|
||||
| `collaboration_access_request` (Access Requests) | Requester/admin visibility, optional share reference, OPEN/GRANTED/DECLINED/WITHDRAWN status and outcome fields. FIELD audit; no parent-data lookup. |
|
||||
| `collaboration_task` (Tasks) | Title, markdown notes, assignee, due/reminder/delivery times and completion fields. Parent security, FIELD audit and title search metadata. |
|
||||
| `collaboration_record_assignment` (Record Owners) | Unique parent reference with owner/assignee. Admin-only nav with HIDDEN denial, parent security and FIELD audit. |
|
||||
| `collaboration_follow_digest` (Follow Digests) | Service-managed pending five-minute batches, unique parent + recipient, due timestamp and changed-field summaries. Admin browse only, outside navigation. |
|
||||
|
||||
The home reports persisted enabled settings by scope type, people known and people seen within the inclusive last seven days, plus Favorites and Recently viewed row counts and distinct user counts, and attachment count and total bytes. Attachment aggregates are explicitly approximate until core can mirror parent locks. Recent activity is based on `lastSeenAt`, independently of the directory's `active` account flag. Empty states explain missing data. These counts do not represent resolved feature enablement on host tables.
|
||||
All tables include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold.
|
||||
|
||||
The home is four full-width standard widgets (no custom HTML, no hard-coded colors, so dark mode works): an Overview of KPIs (comments this week, open threads, open and overdue tasks, open access requests, attachments with a human-readable total size such as 4.7 KB, active custom fields), a Tags table (label, table or All tables, records), a Custom fields table (name, table, status, index state) and an admin-only Who's here table of live viewer counts per table. Every block has a real empty-state message. Counts use the caller's session and permissions; attachment aggregates remain approximate until core can mirror parent locks.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Behavior |
|
||||
|---|---|
|
||||
| `collaboration.view` | Browse Attachments, read Feature Settings, People, the app and home. Not required for personal favorites or recents. |
|
||||
| `collaboration.admin` | Write feature settings, read the app/home, access all users' favorite/recent rows, and run recents retention. Can moderate others' attachments after the parent check. Does not permit direct People or attachment table writes. |
|
||||
| `collaboration.view` | Browse Comments, their revisions/mentions and Attachments; read Feature Settings, People, the app and home. Not required for personal favorites or recents. |
|
||||
| `collaboration.admin` | Write feature settings, read the app/home, access all users' favorite/recent rows, and run recents retention. Can moderate others' attachments and delete others' comments after the parent check. Browses Followers, Activity and Follow Digests. Does not permit editing another author's comment or bypassing service-only table writes. |
|
||||
| `collaboration.fields.manage` | Reserved constant for the later custom-field designer. |
|
||||
| `collaboration.tags.manage` | Reserved constant for later tag management. |
|
||||
|
||||
@@ -88,12 +221,11 @@ List<FavoriteEntry> recents = RecentsService.list(50);
|
||||
|
||||
- Star and unstar are idempotent. New stars append to the existing sort order. Reorder moves the supplied IDs first and preserves the order of omitted favorites; folder entries may be null to clear a folder. All supplied IDs must belong to the current user.
|
||||
- Targets are checked using core table/app/process/report permissions; record targets use `GetAction` and the session's record locks. Invisible and missing targets both fail with `Not found`.
|
||||
- Lists refresh labels, using one batched record query per target table per batch. Favorites retain unavailable entries as `available=false`, `label="Unavailable"`, `href=null`; the cached label is never returned. Unstar remains possible after access is lost. Recents drop unavailable entries and refill from older rows up to the requested limit (1–50).
|
||||
- Lists refresh labels, using one batched record query per target table per batch. Favorites retain unavailable entries as `available=false`, `label="Unavailable"`, `href=null`; the cached label is never returned. Unstar remains possible after access is lost. Recents drop unavailable entries and refill from older rows up to the requested limit (1–50).
|
||||
- `FavoriteEntry` contains `id`, `targetType`, `targetName`, `recordId`, `label`, `folder`, `sortOrder`, `available` and `href`. Recents use the same target DTO in newest-first order; their folder and sort order are null. `viewCount` and `lastViewedAt` remain available on the underlying recent table.
|
||||
- Hrefs mirror Next's accepted flat routes: `/app/<table>/`, `/app/<table>/<id>/`, `/app/<app-or-process-or-report>/`, and `/app/<table>/savedView/<id>/`. Segments are URL-encoded. This avoids reconstructing nested app paths; trailing slashes support dotted process names.
|
||||
- Record views upsert a unique personal target. If its persisted `lastViewedAt` is less than 60 seconds ago, nothing is written; otherwise its timestamp, cached label and count update. The hidden backend-only `collaborationTrimRecents` process retains the newest `recentsPerUser` targets independently for each user, breaking timestamp ties by ID.
|
||||
|
||||
The UI API endpoints in spec §10.2 will arrive in a **follow-up PR once QQQ's UI API extension point ([QRun-IO/qqq#1128](https://github.com/QRun-IO/qqq/pull/1128)) is released**. This PR adds no endpoint or unreleased core dependency. Runtime feature overrides and the record-feature summary SPI also await released core contracts; the home counts are implemented now.
|
||||
|
||||
## Attachments
|
||||
|
||||
@@ -117,37 +249,100 @@ File names are reduced to a safe basename (maximum 255 characters). Object keys
|
||||
|
||||
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.
|
||||
|
||||
RC.5 has no PRE_QUERY customizer. `AttachmentSecurity` uses its custom permission checker as the pre-query hook, adding `tableName IN (readable parent tables)` to queries, counts and aggregates. Enable core permission enforcement for direct model API use. A POST_QUERY customizer checks each row's parent and removes invisible rows before signing any URLs. This applies to direct get/query as well as browsing. **Counts and aggregate bytes are approximate, and pages may be short** because parent record locks are checked after querying; the home labels the approximation. RC.5 cannot publish an `approximate` flag or refill pages through this hook. Replace this stand-in with core's record-reference security when released. Per-table feature enablement/settings and permission catalog integration also await those contracts.
|
||||
Core reference metadata now restricts parent tables/features and enforces parent visibility and record feature permissions. Direct writes remain service-only. The qbit retains its unpinned row-visibility fallback because core does not yet post-filter unmirrored parent locks or refill pages. Counts and aggregates remain approximate.
|
||||
|
||||
**Preview and download.** The record view has `FILE_PREVIEW` and `FILE_DOWNLOAD` virtual fields, filled with five-minute presigned GETs only for visible rows (up to 200 rows per query). Downloads use RC.2's `PresignGet.downloadAs(fileName)`. RC.2 has no arbitrary response-header override for explicit inline disposition, so uploads store `Content-Disposition: inline`, and previews leave that header unchanged. URLs are never persisted. Images/PDF use the standard media preview; the host must allow its storage host in the frontend CSP (PDF frames need compatible `frame-src`). `thumbnailKey` is nullable and unused: object storage has no thumbnail generator, so thumbnails use FILE_PREVIEW rather than generated thumbnail files.
|
||||
|
||||
**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 and the panel transport wait for [#1128](https://github.com/QRun-IO/qqq/pull/1128); direct/presigned upload endpoints, activity entries and realtime events remain future integrations. 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. 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.
|
||||
|
||||
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.
|
||||
|
||||
## Comments, follow and timeline
|
||||
|
||||
Run services in the caller's `QContext`. Register host tables before producing the qbit when using the follow configuration lists:
|
||||
|
||||
```java
|
||||
config.withNotifier(new QCodeReference(MyCollaborationNotifier.class))
|
||||
.withUserSessionProvider(new QCodeReference(MyUserSessions.class))
|
||||
.withFollowActionTables(List.of("order"))
|
||||
.withFollowChangeTables(List.of("order"))
|
||||
.withSchedulerName("scheduler");
|
||||
|
||||
CommentsService comments = new CommentsService();
|
||||
CommentEntry root = comments.post("order", "123", "Please review @[Sam](user:sam)", null, null);
|
||||
CommentEntry reply = comments.post("order", "123", "Reply", root.id(), null);
|
||||
CommentPage page = comments.list("order", "123", null, 20);
|
||||
comments.edit(reply.id(), "Updated reply");
|
||||
comments.history(reply.id());
|
||||
comments.resolve(root.id());
|
||||
comments.reopen(root.id());
|
||||
comments.delete(reply.id());
|
||||
comments.mentionCandidates("order", "123", "Sam");
|
||||
|
||||
FollowService follow = new FollowService();
|
||||
follow.follow("order", "123", null, null);
|
||||
follow.state("order", "123");
|
||||
follow.followers("order", "123");
|
||||
follow.unfollow("order", "123");
|
||||
TimelinePage timeline = new TimelineService().timeline("order", "123", null, 20, Set.of());
|
||||
```
|
||||
|
||||
Classes live in the `comments`, `follow`, `timeline`, `people` and `notify` packages under `com.kingsrook.qbits.collaboration`.
|
||||
|
||||
- Comments require visible parents and the `comment` feature check (explicit `<table>.comment` or table EDIT). Markdown is nonblank and at most 20,000 characters; clients must sanitize rendered HTML. Replies to replies attach to the root. Roots have null `threadId`; replies store the root ID in both thread/parent fields. Deleted DTO bodies are null. Authors alone edit; authors with comment permission or admins delete. Resolve/reopen applies only to roots. History follows parent READ.
|
||||
- Mentions use `@[Name](user:<userId>)`, deduplicate IDs and reject more than 50. `CollaborationRecordAccessChecker.canRead` returns `Optional<Boolean>` using a supplied recipient session and an enforced GetAction in a temporary context. Missing providers/sessions and mismatched identities are unknown. False/unknown recipients get neither record data nor automatic follows. Edits notify only previously unmentioned users. Mention delivery and ordinary follower delivery are deduplicated; authors never receive their own comment notifications.
|
||||
- `CommentEntry` includes author display name, capabilities, deleted/resolved state, mentions with visibility, and ordered replies. `CommentPage` pages 1–50 root threads by latest activity then ID, with replies oldest first. Pass `nextCursor` unchanged. Cursors are opaque, versioned Base64 tuple encodings, not authorization tokens.
|
||||
- Manual/assigned follow creation defaults change notifications on; other reasons default them off. Comment notifications default on. Existing preferences survive automatic follows and repeated calls; non-null arguments to `follow` change preferences. Follow needs only parent READ. The internal `ensureFollower` helper also verifies other-user access. Muted subscriptions are skipped until `mutedUntil` expires.
|
||||
- `FollowProcesses.processesFor(tableName)` returns the same Follow/Unfollow processes for manual host registration. Review performs no writes; Result rechecks permissions and reports successes/failures. They work with the host's actual primary-key field, not just `id`, and are never navigation entries.
|
||||
- RC.5 has `RecordChangeListenerInterface` and `QBackendTransaction.addAfterCommitCallback`. The configured UPDATE listener queues changed-field summaries after commit, excluding the actor's own subscription. The trusted `FollowChangeBatcher.recordChange` helper may also be called by host code after its write commits. `collaborationSendFollowDigests` runs every minute and sends batches due five minutes after their first change, rechecking preferences, mute and recipient access. Pending data survives process restarts. Run one digest scheduler per installation; local synchronization is not a distributed lock. A transport crash after delivery but before row deletion can cause a retry; an adapter needing exactly-once delivery must provide idempotency.
|
||||
- Notifications carry recipient ID, title, markdown body, MENTION/SHARE/FOLLOW/TASK category, source table/record and `/app/<table>/<id>` href. Without a transport, `NoOpNotifier` sends nothing and activity remains available. With a `QContext` transaction, delivery waits for commit and is discarded on rollback; otherwise it follows the write immediately. Access is checked again at delivery. Mention `notifiedAt` is recorded only after a real transport returns successfully. `RecordingNotifier` provides an in-memory test sink.
|
||||
- Timeline merges collaboration activity and RC.5 `audit`/`auditDetail`/`auditTable`/`auditUser` through enforced QueryActions. Missing or denied audit access omits that stream. Audit details exclude fields absent from the caller's personalized table. Core audits have actor names but no session user IDs, so their `actorUserId` is null. Paging uses `(at, kind, id)`; `types` accepts AUDIT, ACTIVITY or individual activity type names. Each source query is bounded before merging.
|
||||
- `PeopleService.touch()` upserts the signed-in user's full name and last-seen timestamp at most once per ten minutes, using the persisted timestamp. Comments/follow and existing favorites/recents/attachment entry points call it. `search(q, limit)` matches active users' names/emails case-insensitively. QUser in RC.5 has no email/avatar fields; trusted directory integrations can enrich those columns. Mention candidates rank followers/commenters before applying the 50-result limit.
|
||||
|
||||
The main comments/activity tables, revision/mention child joins and Followers child list are ordinary QQQ metadata. Direct API writes are refused; services authorize the actual parent. Shared `ParentTableRestriction` protects attachment and conversation queries/counts/aggregates; parent post-filtering protects row reads. Aggregate counts remain approximate and browse pages can be short for unmirrored parent locks. Raw persistence, activity writing and change-hook helpers are trusted internal boundaries and must not become public endpoints.
|
||||
|
||||
UI API endpoints are described below. Runtime feature enablement, mirrored WRITE locks and core record-access/session contracts remain replacement points; the services do not claim to resolve feature-setting overrides. QAlerts.notify integration waits for #1125. Host-configured ESB listeners receive ordinary QQQ writes; realtime/UI and Quick Search provider registration remain host/later-phase integrations.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `backendName` | Required | Existing QQQ backend used by all five tables. |
|
||||
| `schedulerName` | None | Existing scheduler for the hidden daily recents trim process (86400-second interval and initial delay). Without it, the process is available for explicit admin/system execution. |
|
||||
| `backendName` | Required | Existing QQQ backend used by all eleven tables. |
|
||||
| `schedulerName` | None | Existing scheduler for daily recents trimming and minute-by-minute follow digest delivery. Without it, both hidden processes remain available for explicit admin/system execution. |
|
||||
| `attachmentsBucketName` | `collaboration` | PRIVATE object-storage bucket alias for new uploads; existing rows retain their bucket alias. |
|
||||
| `defaultMaxBytes` | `104857600` (100 MiB) | Positive byte limit, capped by the bucket's `maxUploadBytes`. |
|
||||
| `blockedExtensions` | Empty | Case-insensitive extension denylist, with or without leading dots. |
|
||||
| `recentsPerUser` | `200` | Positive retention count per user. Enforced by the trim process. |
|
||||
| `notifier` | None | Implements `CollaborationNotifier`; replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released. When unset: `EmailCollaborationNotifier` if qbit-email is on the classpath and produced in the instance, otherwise `NoOpNotifier`. |
|
||||
| `recipientEmailResolver` | People directory | `Function<String, String>` from user id to email address for the email notifier. Unset reads the `email` of the matching `collaboration_user` row; no address means no email. |
|
||||
| `userSessionProvider` | None | `QCodeReference` implementing `UserSessionProviderInterface`; missing user sessions mean unknown access. |
|
||||
| `followActionTables` | Empty | Existing host tables to receive standard Follow and Unfollow Review/Result processes. |
|
||||
| `followChangeTables` | Empty | Existing host tables whose UPDATE events should produce five-minute follow digests. |
|
||||
| `appName` | `collaboration` | Metadata name of the app. |
|
||||
| `parentAppName` | None | Optional existing parent, such as `admin`. |
|
||||
| `adminApp` | `true` | Set false for tables without the app and widget. |
|
||||
| `adminApp` | `true` | Set false to omit the app/home. Record child-list widgets remain available. |
|
||||
|
||||
## Email through qbit-email
|
||||
|
||||
qbit-email 0.1.0-RC.2 is an optional dependency (`<optional>true</optional>`). The integration uses its published `com.kingsrook.qbits.email.api` package for installation detection, template registration and sending. When it is on the classpath, produced in the same instance, and `notifier` is not configured, mentions, shares and task notifications are emailed by `EmailCollaborationNotifier`. Without qbit-email nothing changes: `NoOpNotifier` sends nothing. A configured `notifier` always wins.
|
||||
|
||||
The guardrail allowlist properties retain the object-storage API and `*-api` artifact allowances and add only the qbit-email artifact and `com.kingsrook.qbits.email.api` package. The current parent 2.0.0 does not execute guardrails; these properties are ready for a parent that does. Tests retain `EmailQBitProducer`/`EmailQBitConfig` only to assemble the host instance; G1 checks production classes and sources.
|
||||
|
||||
- Templates (NOTIFICATION, Markdown, owner `qbit-collaboration`), registered on first send and never overwritten: `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder` (task assignment, reminder and overdue). Follow digests are not emailed.
|
||||
- One message per recipient, idempotency key `collaboration:<mention|share|task>:<event id>:<recipient user id>`, linked to the parent record, sender `qbit-collaboration`. A repeat is a duplicate. The event id is the mention, share, task or assignment row id (task reminders add the due field); `CollaborationNotification` carries it as `sourceId`, with the acting user as `actorUserId`.
|
||||
- No comment bodies, task titles, notes or share messages are emailed, since they may hold PHI. The email says who did what on which record, with a link. The record label is read as the recipient (needs `userSessionProvider`) and used only when `FieldAccessHelper.isRecordLabelFullAccess` allows it (reflective guard); otherwise the text says "a record in <table label>". Variables are classified CONFIDENTIAL so the templates need no PHI approval.
|
||||
- The link is sent only when absolute: set `frontendBaseUrl`.
|
||||
- Recipient addresses come from `withRecipientEmailResolver(Function<userId, String>)`, else the people directory email. Refused or suppressed sends are logged by user id and status only; delivery problems never fail the collaboration action.
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
1. Register the host backend and optional Admin app, then invoke the producer above.
|
||||
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 Attachments, Feature Settings and People. Expect empty tables until settings or trusted directory integrations provide rows.
|
||||
5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. Favorites, recents and attachments services can be used now. This phase installs no endpoints or host-table overlays; recents scheduling is opt-in.
|
||||
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.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -162,3 +357,412 @@ Offline verification: `./mvnw -o -q verify` (Windows: `./mvnw.cmd -o -q verify`)
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE). Copyright 2026 Kingsrook, LLC; see [NOTICE](NOTICE).
|
||||
|
||||
## Tags
|
||||
|
||||
Phase d builds against **QQQ 4.1.0-RC.5**. Register host actions with
|
||||
`config.withTagActionTables(List.of("order"))`; `withDefaultTagMode("FREE")` is the
|
||||
default. Set `collaboration_feature_setting.feature = TAGS` and JSON
|
||||
`{"mode":"APPROVED_ONLY"}` to require approval. The service reads settings as the
|
||||
system, in TABLE → preferred APP → INSTANCE order, falling back to config. The
|
||||
first scope containing `mode` wins; `enabled` remains part of future general
|
||||
feature resolution. Invalid policy JSON or mode fails closed.
|
||||
|
||||
`TagsService` provides:
|
||||
|
||||
- `vocabulary(table, query, limit)`: global and table vocabulary, approved tags plus
|
||||
the caller's pending suggestions, excluding merged aliases; usage descending,
|
||||
then label. Default limit 20, maximum 100.
|
||||
- `create(tableOrNull, label, color)`: trimmed, lowercase, collapsed name of 1–60
|
||||
characters; the label retains its case. Duplicate names reuse the existing tag.
|
||||
Per-table creation requires `<table>.tag` or table EDIT and READ. FREE creates
|
||||
approved tags; APPROVED_ONLY creates suggestions unless the caller holds
|
||||
`collaboration.tags.manage`. Global creation always requires that manage grant.
|
||||
- `tagsFor(table, recordId)` and `apply(table, recordId, add, remove)`: parent READ
|
||||
and record locks are checked first. Writes also require the tag feature grant or
|
||||
EDIT. Numeric values are tag IDs; strings are labels. Unknown add labels are
|
||||
created in FREE; in APPROVED_ONLY they fail with
|
||||
“Pick an approved tag (or suggest one)”. Pending tags cannot be applied by anyone.
|
||||
Repeated changes are idempotent; actual changes write TAGGED/UNTAGGED activity,
|
||||
recount usage, and do not notify followers.
|
||||
|
||||
Tags is a main navigation table with FIELD auditing and native label search.
|
||||
Record Tags is a secured child list. Both use service-managed writes. Standard
|
||||
streamed **Approve tags**, **Merge tags**, and **Delete tags** actions require
|
||||
`collaboration.tags.manage`; Delete Review counts distinct affected parents.
|
||||
Merge deduplicates references and retains the source as an alias. Targets must be
|
||||
approved and in the source scope or global; a global source cannot merge into a
|
||||
single table's vocabulary.
|
||||
|
||||
Host **Add tags** and **Remove tags** actions use the table-type
|
||||
`collaboration_tag` PVS, filtered to approved, unmerged global or same-table tags.
|
||||
Each record is rechecked during Review and execution. Input `tags` is a list of
|
||||
integer IDs (comma-separated IDs are also accepted); `targetTagId` is a scalar.
|
||||
The picker step exposes `EDIT_FORM` with `multiSelectFields = ["tags"]`. RC.5 has
|
||||
no native multi-valued field type: rendering this component-value contract as a
|
||||
multi-select belongs to the separate Next UI phase. The qbit tests exercise the
|
||||
submitted arrays and real streamed Review/Result lifecycle.
|
||||
|
||||
`AttachmentCleanup.onParentsDeleted` now also removes tag assignments and
|
||||
recounts vocabulary usage. Core invokes `CollaborationCleanup` after committed parent deletion. The home shows session-visible vocabulary
|
||||
size, pending suggestions, and the five most-used approved tags.
|
||||
|
||||
`collaborationTags` is a virtual STRING field labeled Tags with possible-value source `collaboration_tag`. `TagCriteriaExpander` supports IN, NOT_IN, EQUALS, NOT_EQUALS, IS_BLANK, IS_NOT_BLANK and CONTAINS_ALL using core `QSubQuery` membership filters. Ordinary quick search matches labels; `#name` matches normalized names. Numeric criteria are tag IDs; string criteria are names. The old ID materializer is removed. Core owns SQL execution or bounded cross-backend materialization; its bound applies to inner rows, not final parent matches.
|
||||
|
||||
The runtime provider adds this field and its search entry where TAGS is enabled. It is queryable and selectable (`TagValueProvider` fills it after host customizers have run).
|
||||
|
||||
### What the Tags field costs a read
|
||||
|
||||
Core selects every selectable virtual field for a query or get that names no fields, so each such read of a table with TAGS enabled runs `TagValueProvider`, including the reads other code makes one record at a time (a get, the stored-record read before an update, a lookup made per row). The provider therefore costs:
|
||||
|
||||
- nothing for a table that has no tag assignment. Each node remembers that one fact per table (`TaggedTables`) for `withTagLookupCacheSeconds` (default 30; 0 looks every time) and learns it with one indexed `LIMIT 1` statement;
|
||||
- one assignment statement and one vocabulary statement per 500 parents for a table that has assignments. Record tags themselves are never cached.
|
||||
|
||||
A node that writes an assignment forgets the table's answer at once and again after commit, and publishes core's `memo-invalidate` cluster event (`ClusterCaches`, name `collaboration.taggedTables`). Hosts with a ClusterBus transport see a table's first tag on every node immediately; without one, another node shows it after at most the configured seconds. The same bound covers assignments written outside QQQ. The answer does not depend on the session (the probe ignores row locks and returns no data), so it cannot change which records or tags a caller sees. A failed probe is thrown, not remembered.
|
||||
|
||||
## UI API
|
||||
|
||||
The producer registers extension `collaboration` version `1.2.0` at
|
||||
`/qqq/v1/qbit/collaboration`. It uses the signed-in user's session and existing
|
||||
services. Disable registration with `new CollaborationQBitConfig().withUiApi(false)`.
|
||||
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. 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`.
|
||||
|
||||
| Method | Path (relative to `/qqq/v1/qbit/collaboration`) | Purpose | Request schema | Response schema |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/favorites` | List personal favorites | — | `CollaborationFavoriteListV1` |
|
||||
| PUT | `/favorites` | Star a target | `CollaborationTargetRequestV1` | `CollaborationMutationResultV1` |
|
||||
| DELETE | `/favorites` | Unstar a target | `CollaborationTargetRequestV1` | `CollaborationMutationResultV1` |
|
||||
| POST | `/recents` | Record a recent view | `CollaborationTargetRequestV1` | `CollaborationMutationResultV1` |
|
||||
| PATCH | `/favorites/order` | Reorder personal favorites | `CollaborationFavoriteOrderRequestV1` | `CollaborationMutationResultV1` |
|
||||
| GET | `/recents` | List recent targets | — | `CollaborationRecentListV1` |
|
||||
| GET | `/records/{table}/{pk}/attachments` | List record attachments | — | `CollaborationAttachmentListV1` |
|
||||
| GET | `/attachments/{id}/download` | Get a temporary download URL; clients follow url | — | `CollaborationDownloadV1` |
|
||||
| GET | `/attachments/{id}/preview` | Get a temporary preview URL; clients follow url | — | `CollaborationDownloadV1` |
|
||||
| PATCH | `/attachments/{id}` | Rename or describe an attachment | `CollaborationAttachmentPatchRequestV1` | `CollaborationAttachmentV1` |
|
||||
| DELETE | `/attachments/{id}` | Delete an attachment | — | `CollaborationMutationResultV1` |
|
||||
| GET | `/tags` | Search table tag vocabulary | — | `CollaborationTagListV1` |
|
||||
| POST | `/tags` | Create or suggest a tag | `CollaborationTagCreateRequestV1` | `CollaborationTagV1` |
|
||||
| GET | `/records/{table}/{pk}/tags` | List applied record tags | — | `CollaborationTagListV1` |
|
||||
| PUT | `/records/{table}/{pk}/tags` | Add and remove record tags | `CollaborationTagApplyRequestV1` | `CollaborationTagListV1` |
|
||||
| GET | `/records/{table}/{pk}/comments` | Page record comment threads | — | `CollaborationCommentPageV1` |
|
||||
| POST | `/records/{table}/{pk}/comments` | Post a comment or reply | `CollaborationCommentPostRequestV1` | `CollaborationCommentV1` |
|
||||
| PATCH | `/comments/{id}` | Edit the caller's comment | `CollaborationCommentEditRequestV1` | `CollaborationCommentV1` |
|
||||
| DELETE | `/comments/{id}` | Delete a comment thread | — | `CollaborationMutationResultV1` |
|
||||
| POST | `/comments/{id}/resolve` | Resolve a comment thread | — | `CollaborationMutationResultV1` |
|
||||
| POST | `/comments/{id}/reopen` | Reopen a comment thread | — | `CollaborationMutationResultV1` |
|
||||
| GET | `/comments/{id}/history` | Read comment edit history | — | `CollaborationCommentHistoryV1` |
|
||||
| GET | `/mentions/candidates` | Find people to mention on a record | — | `CollaborationMentionCandidateListV1` |
|
||||
| GET | `/records/{table}/{pk}/follow` | Get the caller's follow state | — | `CollaborationFollowStateV1` |
|
||||
| PUT | `/records/{table}/{pk}/follow` | Follow a record and configure notifications | `CollaborationFollowRequestV1` | `CollaborationFollowStateV1` |
|
||||
| DELETE | `/records/{table}/{pk}/follow` | Unfollow a record | — | `CollaborationFollowStateV1` |
|
||||
| GET | `/records/{table}/{pk}/followers` | List record followers | — | `CollaborationFollowerListV1` |
|
||||
| GET | `/records/{table}/{pk}/timeline` | Page the merged record timeline | — | `CollaborationTimelinePageV1` |
|
||||
| GET | `/people` | Search the active people directory | — | `CollaborationPersonListV1` |
|
||||
| GET | `/people/{userId}` | Get an active directory person | — | `CollaborationPersonV1` |
|
||||
| POST | `/records/{table}/{pk}/attachments` | Upload one file (multipart) | `CollaborationAttachmentUploadRequestV1` | `CollaborationAttachmentV1` |
|
||||
|
||||
List requests accept `limit` (default 20, range 1-100). Responses include
|
||||
`nextCursor`: pass it as `cursor` to continue. Comments page root threads by last
|
||||
activity; replies are oldest first. Timeline uses its `(at, kind, id)` keyset and
|
||||
also accepts `before` as an alias for `cursor` (supply only one), plus comma-separated
|
||||
`types` (`AUDIT`, `ACTIVITY`, or activity names such as `COMMENTED`). Complete-list
|
||||
services use opaque offset cursors and recheck visibility on every request;
|
||||
concurrent list changes may shift offsets. Recents and mention candidates retain
|
||||
their service's 50-item window. People and vocabulary search accept `q` and `limit`
|
||||
only and return null `nextCursor`; narrow the search to find more matches. Tag apply
|
||||
returns the full resulting tag set.
|
||||
|
||||
Upload is real `multipart/form-data`, with exactly one binary part named `file`
|
||||
and an optional text `description`. The direct `AbstractEndpointSpec` reads Javalin's
|
||||
uploaded parts and streams the file to `AttachmentsService`, which enforces parent
|
||||
access, filename rules and bounded storage. Hosts retain the default
|
||||
`AttachmentStorage` adapter backed by object storage RC.2; an alternative adapter
|
||||
can be configured with `withAttachmentStorage(new QCodeReference(Adapter.class))`.
|
||||
|
||||
Download and preview both return `CollaborationDownloadV1 {url}` with status 200:
|
||||
**clients follow url**. The presigned URL lasts five minutes. Download requests an
|
||||
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 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.
|
||||
|
||||
QQQ's schema model currently lacks object-property `required` and numeric
|
||||
minimum/maximum keywords. Required fields and limit bounds are described in the
|
||||
schemas and enforced at runtime; the request body itself is marked required.
|
||||
|
||||
Regenerate the fragment after intentionally changing the contract:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd -o -q '-Dtest=CollaborationApiContractTest' '-Dcollaboration.openapi.update=true' test
|
||||
```
|
||||
|
||||
Normal `mvnw.cmd -o -q verify` checks the fragment without modifying it, runs real
|
||||
HTTP integration tests on a free port with the memory backend, and retains the
|
||||
70% instruction / 90% class coverage gates. This is the first release of the
|
||||
extension, so there is no prior release baseline yet.
|
||||
## Sharing and work services (phases h/i)
|
||||
|
||||
Services run in the caller's `QContext`. UI API endpoints and screens follow in a separate PR.
|
||||
|
||||
```java
|
||||
SharingService sharing = new SharingService();
|
||||
sharing.link("order", "123"); // Link(url, label)
|
||||
sharing.preview("order", "123", List.of("sam")); // canRead: true, false or null (unknown)
|
||||
sharing.share("order", "123", List.of("sam"), "Please review");
|
||||
sharing.markOpened(shareId); // recipient only
|
||||
|
||||
AccessRequestService access = new AccessRequestService();
|
||||
access.request("order", "123", "Please grant access", shareId); // optional shareId
|
||||
access.withdraw(requestId);
|
||||
|
||||
TasksService tasks = new TasksService();
|
||||
QRecord task = tasks.create("order", "123", "Review order", "Markdown notes", "sam", dueAt, null);
|
||||
tasks.complete(task.getValueInteger("id"));
|
||||
tasks.reopen(task.getValueInteger("id"));
|
||||
tasks.cancel(task.getValueInteger("id"));
|
||||
tasks.list("order", "123", false);
|
||||
tasks.mine("OPEN", 20, null); // Page(tasks, nextCursor)
|
||||
|
||||
AssignmentService assignments = new AssignmentService();
|
||||
assignments.assign("order", "123", "owner", "sam");
|
||||
assignments.get("order", "123");
|
||||
assignments.myRecords(null, 20); // owner OR assignee; per-table count and first 20
|
||||
new MyWorkService().summary();
|
||||
new MyWorkService().markMentionsRead(null); // all visible unread mentions; or supply IDs
|
||||
```
|
||||
|
||||
Set `frontendBaseUrl` for absolute record links; omission produces
|
||||
`/app/{encodedTable}/{encodedId}`. Sharing and notifications reuse `TargetResolver.recordHref`.
|
||||
This is trusted host configuration. Sharing requires parent READ and `<table>.share` or EDIT,
|
||||
1-25 recipients (deduplicated), and a message of at most 2,000 characters. Readers get the
|
||||
record label/message/link and SHARED following. False/unknown recipients get only sender name,
|
||||
table label and an access-request link. The sender's message is omitted because it may contain
|
||||
record data. The prefill route for the follow-up UI is:
|
||||
|
||||
`/app/collaboration_access_request/create?tableName={encodedTable}&recordId={encodedId}&shareId={id}`
|
||||
|
||||
The UI consumes these parameters and calls `request`; a supplied share ID must match its
|
||||
recipient and parent reference. Sharing never grants access. Parent-data notifications recheck
|
||||
recipient access at delivery, after commit. Access notices contain no parent data and can reach
|
||||
nonreaders.
|
||||
|
||||
Any signed-in user may request access, without a parent read or existence check. Existing and
|
||||
nonexistent targets return the same request shape and OPEN status; generated IDs/timestamps
|
||||
and submitted targets naturally differ. Duplicate OPEN requests return the existing row.
|
||||
A PostgreSQL partial unique index protects that natural key across instances. Ordinary table
|
||||
reads are requester/admin only; all direct writes are denied. Approver actions use an explicitly
|
||||
authorized extractor without widening ordinary table visibility.
|
||||
|
||||
Set TABLE-scope SHARING `settings` to `{"accessApproverPermission":"order.approve"}` (default:
|
||||
`collaboration.admin`). Configure `.withPermissionHolderDirectory(new QCodeReference(MyDirectory.class))`;
|
||||
`sharing.PermissionHolderDirectory.userIdsWithPermission(permission)` returns stable user IDs.
|
||||
Recipients are the union of admin holders, configured approver holders and the qbit record owner.
|
||||
Without a directory, only the owner is notified and a warning is logged once per JVM. Mark granted
|
||||
and Decline accept an optional note and tell the requester the outcome. Review explicitly says
|
||||
"Record the outcome; grant access in your identity system first." No permission is changed.
|
||||
Notifications omit free-form messages and notes.
|
||||
|
||||
Task create requires `task`. Changes additionally require creator, assignee or admin authority,
|
||||
while retaining parent visibility and task permission checks. Titles contain 1-200 characters;
|
||||
notes are markdown TEXT. An omitted create assignee defaults to self. Other assignees need a
|
||||
positive read check; false/unknown is refused with "Sam can't open this record." The full-field
|
||||
`update(id, title, notes, assignee, due, remind)` replaces editable fields: null clears an assignee
|
||||
or due time; null remind computes the default from due. The reminder defaults to due minus one
|
||||
day, or due itself when less than a day away. Assignments add ASSIGNED following and notify the
|
||||
new assignee unless self. Creation/completion writes activity and queues notify-on-change follower
|
||||
digests. Reopen clears completion fields but retains delivery history; changing due/reminder time
|
||||
or assignee resets the relevant delivery receipt.
|
||||
|
||||
The hidden `collaborationSendTaskReminders` process runs every 60 seconds with `schedulerName`.
|
||||
It requires an admin/system session and no open transaction. `TaskReminders(Clock)` and
|
||||
`TasksService(Clock)` allow deterministic time. Separate timestamps track successful reminder
|
||||
and overdue delivery; revoked/unknown access and the no-op notifier do not mark delivery.
|
||||
Run one scheduler per installation. A crash between delivery and receipt can cause a retry;
|
||||
stronger guarantees require transport idempotency, as with follow digests.
|
||||
|
||||
Configure `assignActionTables` for streamed Assign to and Take ownership actions. Assign needs
|
||||
`<table>.assign` or EDIT and verifies both roles can read (self passes after the parent check).
|
||||
`assign` replaces both roles, with null clearing a role; bulk actions preserve the other role.
|
||||
For host tables with their own owner/assignee columns, use core `RecordOwnership` metadata and
|
||||
normal host-table updates instead. Do not use the qbit assignment table or configure these bulk
|
||||
actions for such tables. Core phase-i virtual fields, CurrentUser and filter integration remain
|
||||
separate work; RC.5 does not provide that contract.
|
||||
|
||||
My work returns the exact visible open-task count and first ten, assigned-record counts per
|
||||
table, and delivered unread mentions. Personal lists/counts recheck each parent. This portable
|
||||
RC.5 implementation materializes matching candidates for null-last sorting/counting; large
|
||||
installations should replace it with core mirrored-lock queries when available. Task cursors
|
||||
encode `(dueAt, id)` and limits are 1-100. My records limits returned rows per table, counting all
|
||||
visible matches.
|
||||
|
||||
The home adds open access requests, open/overdue tasks and shares in the last seven days.
|
||||
Task/share aggregate counts retain the existing approximation for parent record locks until
|
||||
core mirrors them. `WorkCleanup` is registered for committed host deletes: it removes tasks,
|
||||
assignments and shares, retaining access requests. Attachment cleanup also invokes the
|
||||
idempotent hook. Schema generation includes the four tables, supporting indexes and
|
||||
`ALTER TABLE collaboration_mention ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ` for upgrades.
|
||||
Hosts apply generated SQL through their migration tooling; ESB/search publication uses the same
|
||||
host integrations as other collaboration tables.
|
||||
|
||||
|
||||
## Core contracts and completed UI API
|
||||
|
||||
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)
|
||||
.withTableFeatures("order", RecordFeature.TAGS, RecordFeature.COMMENTS, RecordFeature.FOLLOW)
|
||||
.withMirroredSecurityKeyTypes(List.of("clientId"))
|
||||
.withUserSessionProvider(new QCodeReference(MyUserSessions.class));
|
||||
```
|
||||
|
||||
`withTableFeatures` supplies the complete enabled set for that table. Host declarations and hard limits use `RecordFeaturesMetaData`. After declaring host tables and producing/enriching the instance, call `RuntimeMetaDataOverlay.refresh(instance)` after database initialization with that instance in `QContext`, as required by core. This loads stored policy and initial runtime fields. Feature-setting insert/update/delete refresh again after commit. Rollback discards callbacks; invalid contributions retain the previous accepted snapshot.
|
||||
|
||||
Feature permissions appear automatically in the policy editor through core enrichment. Record-level implied EDIT grants also require the parent's WRITE locks. `mirroredSecurityKeyTypes` defaults to none; configured keys add `lockKey_<type>` fields, null-ALLOW locks and PostgreSQL columns/ALTER statements. Apply generated DDL before enabling the configuration. Core propagates parent lock changes. Revisions and mentions already carry denormalized references in this schema and receive core protection too.
|
||||
|
||||
`CollaborationCleanup` removes attachments, tags, work and conversation data after committed parent deletion, retaining access requests. `CollaborationRecordAccessChecker` implements `RecordAccessCheckerInterface` through core `UserSessionProviderInterface.sessionForUser`; missing/mismatched recipient sessions mean unknown access. `CollaborationSummaryProvider` fills core `GET /qqq/v1/table/{table}/{pk}/features` with favorite/follow state, open comment/task counts, attachment/tag/follower counts and owner/assignee values.
|
||||
|
||||
New routes, all under `/qqq/v1/qbit/collaboration`:
|
||||
|
||||
| Method | Path |
|
||||
| --- | --- |
|
||||
| GET | `/records/{table}/{pk}/link` |
|
||||
| POST | `/records/{table}/{pk}/shares/preview` |
|
||||
| POST | `/records/{table}/{pk}/shares` |
|
||||
| POST | `/access-requests` |
|
||||
| GET | `/access-requests/mine` |
|
||||
| GET, POST | `/records/{table}/{pk}/tasks` |
|
||||
| PATCH | `/tasks/{id}` |
|
||||
| GET | `/tasks/mine` |
|
||||
| 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 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. `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)
|
||||
|
||||
Enable `RecordFeature.CUSTOM_FIELDS` on each host table. **Custom Fields** is the standard,
|
||||
FIELD-audited `collaboration_field_definition` table. Reads require `collaboration.view` (or
|
||||
admin); writes/actions require `collaboration.fields.manage` (or admin). Definitions have a
|
||||
unique `(tableName, name)` key. The CUSTOM table picker lists enabled host tables; this core
|
||||
snapshot has no generic table-name provider. EAV values are a secured child list, not a nav item.
|
||||
|
||||
Create a DRAFT named `[a-z][a-z0-9_]{0,40}`, then run **Publish field**. ACTIVE definitions appear
|
||||
as ordinary `cf_<name>` fields through the overlay. **Archive field** counts records with values
|
||||
in Review and hides the field, retaining its values. This version publishes DRAFT definitions
|
||||
only. **Rebuild index** reruns the index job. Types can change only in DRAFT; table/name identity
|
||||
is immutable. Lifecycle/index fields are server-managed; published definitions cannot be deleted.
|
||||
|
||||
Types: STRING, TEXT, INTEGER, DECIMAL, BOOLEAN, DATE, DATE_TIME, SELECT, MULTI_SELECT, USER and
|
||||
RECORD_LINK. Select options use stable ids, for example:
|
||||
|
||||
```json
|
||||
[{"id":"normal","label":"Normal"},{"id":"urgent","label":"Urgent"}]
|
||||
```
|
||||
|
||||
Active options may be added or relabeled. PostgreSQL containment counts refuse removal of an id
|
||||
still in use; other backends skip this check with a visible warning. USER uses `collaboration_user`
|
||||
PVS; RECORD_LINK requires the linked table's PVS. MULTI_SELECT is a JSON-array **STRING** with a
|
||||
PVS: native array membership operators and multi-select editing await core support. Numeric
|
||||
bounds use `ValueRangeBehavior`; required, maxLength, defaultValue and plain-text help use normal
|
||||
metadata. Optional PUBLIC/CONFIDENTIAL/PII/PHI classification is carried by `AiFieldMetaData`.
|
||||
Sections follow `sectionName`, defaulting to Custom fields. `sortOrder` controls contribution
|
||||
order; searchable definitions join the normal search-field list.
|
||||
|
||||
Definition writes refresh metadata after commit; rollbacks do not. Perform the initial
|
||||
`RuntimeMetaDataOverlay.refresh(instance)` after metadata and database initialization. Other pods
|
||||
must refresh their overlays when definitions change; presence replication does not replicate
|
||||
metadata.
|
||||
|
||||
Memory uses core `JsonPathStorage`. PostgreSQL uses it after a cached
|
||||
`SELECT qqq_custom_fields FROM <backend table> LIMIT 0` probe succeeds. Missing columns and
|
||||
unsupported backends get read-only EAV virtual filters over `collaboration_field_value`, with
|
||||
typed `QSubQuery` criteria. These fields are not query-selectable; EAV value writes and projection
|
||||
are outside this version. `statusMessage` records the fallback reason. After adding a missing
|
||||
column, call `CustomFieldStorage.invalidate(instance, tableName)` and refresh the overlay.
|
||||
|
||||
### PostgreSQL setup and indexes
|
||||
|
||||
Apply the one-time migration with your normal migration tool:
|
||||
|
||||
```sql
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS qqq_custom_fields JSONB NOT NULL DEFAULT '{}';
|
||||
CREATE INDEX IF NOT EXISTS orders_custom_fields_gin ON orders
|
||||
USING gin (qqq_custom_fields jsonb_path_ops);
|
||||
```
|
||||
|
||||
`CollaborationSchema.customFieldsDdl("orders")` generates equivalent SQL with quoted identifiers
|
||||
and a deterministic index name. `postgresScript(config)` includes the two new collaboration
|
||||
tables. Neither helper executes SQL. Do not declare the backing column as a regular QQQ field.
|
||||
|
||||
The hidden `collaborationBuildFieldIndexes` job runs every 60 seconds when `schedulerName` is
|
||||
set, and through Publish/Rebuild/Archive. Definitions show BUILDING/READY/FAILED, SQL and errors.
|
||||
Range fields get typed expression indexes; searchable text uses `gin_trgm_ops` when `pg_trgm`
|
||||
is installed, otherwise a normal expression index. Concurrent DDL uses a fresh backend
|
||||
connection with autocommit, outside the record transaction. PostgreSQL can reject temporal text
|
||||
casts as non-immutable; that failure is recorded. A host migration can supply its chosen
|
||||
immutable expression. Rebuild uses `IF NOT EXISTS`, preserving existing index definitions.
|
||||
Archive drops the index concurrently while retaining values.
|
||||
|
||||
`withManagedIndexes(false)` records `indexDdl` only, with indexState NONE, for host migrations.
|
||||
There is no automatic migration receipt. The home shows definition/index status totals and the
|
||||
number of definitions with diagnostic messages.
|
||||
|
||||
## Presence (phase g)
|
||||
|
||||
Enable `RecordFeature.PRESENCE` on the table. The typed endpoints below
|
||||
`/qqq/v1/qbit/collaboration` are:
|
||||
|
||||
| Operation | Input |
|
||||
| --- | --- |
|
||||
| `POST /presence` | `{connectionId, table, pk?, mode: "VIEWING"|"EDITING", fieldName?}` |
|
||||
| `DELETE /presence` | `{connectionId, table, pk?}` |
|
||||
| `GET /presence?table=...&pk=...&perConnection=true` | Omit pk for a table snapshot; `perConnection` is optional |
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Per-connection presence (extension 1.2.0, #54): each merged viewer carries `connections`, the
|
||||
number of live leases (tabs, terminals) behind it, and every snapshot carries `leaseCount`, so
|
||||
two terminals of one user read as one person with two connections. `perConnection=true` adds
|
||||
`leases`, one viewer per lease with an opaque `connectionId` (`c` and the first 16 hex digits of
|
||||
SHA-256 of `userId`, a newline and the client connection id), its `mode`, `fieldName` (record
|
||||
targets only), `sessionId` and `since`, the lease start. `presence.changed` events always carry
|
||||
`leaseCount` and `leases`. Session uuids are bearer credentials, so a lease never stores or
|
||||
publishes one: it keeps `sessionId`, `s` and the first 16 hex digits of HMAC-SHA256 of the
|
||||
session uuid under `QQQ_PRESENCE_SESSION_SECRET` (system property or environment; set the same
|
||||
value on every pod so ids agree) or else a random per-process secret. `PresenceRegistry.counts()` still counts users; `connectionCounts()` counts leases.
|
||||
|
||||
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.
|
||||
|
||||
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 and
|
||||
connection counts per table, and core record-feature summaries include the presence snapshot.
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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.
|
||||
- Reads of tables without any tag assignment skip the assignment lookup (`withTagLookupCacheSeconds`, default 30 seconds, 0 restores a lookup on every read). A host running several nodes without a ClusterBus transport shows a table's very first tag on the other nodes after at most that long; later tags on that table show at once, as before.
|
||||
- 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,81 @@
|
||||
# Attachment lifecycle extension
|
||||
|
||||
Collaboration owns standard attachment storage and record access. A host can configure `CollaborationQBitConfig.withAttachmentLifecycle(new QCodeReference(HostPolicy.class))` to enforce retention, registration and mandatory access-event persistence. `HostPolicy` extends `AttachmentLifecyclePolicy`. Collaboration has no dependency on Record Files, generation, scripting or a particular event store.
|
||||
|
||||
The default policy preserves legacy behavior for ordinary attachments. Invalid policy loading fails before storage mutation. This is a trusted Java extension, never an entry point for untrusted user scripts. Policy implementations must be safe for concurrent requests and may not depend on per-instance state surviving between operations.
|
||||
|
||||
## Contract
|
||||
|
||||
| Hook | Timing and meaning |
|
||||
| --- | --- |
|
||||
| `isRetained(attachment)` | Called before object storage during intake with complete metadata but no generated id. Its result is persisted in the server-controlled `retained` field. Also consulted for legacy rows during deletion and display hints. A persisted true value always wins. |
|
||||
| `beforeStored(attachment)` | Called with canonical metadata and actual checksum immediately before the first object PUT, after matching retry replay returns. Retained files default to refusal; a mandatory provider commits storage intent independently. Retained uploads reject ambient transactions. |
|
||||
| `afterStored(attachment)` | Called after object and attachment metadata insertion, before upload returns. Register the retained identity and ingestion status. Failure preserves the object and inserted row for reconciliation when the enclosing transaction permits it. |
|
||||
| `beforeAccess(attachment, inline)` | Called after parent authorization, before signing either standard preview or download URLs. Throw to deny. Runtime policy failures are converted to checked failures so preview code cannot silently swallow them. |
|
||||
| `beforeMetadataChange(attachment, changes)` | Called after parent/ownership checks, before applying filename/description changes. The second record contains id and changed fields only. This represents an intent, not a committed change. |
|
||||
|
||||
Callbacks receive record copies; scalar attachment metadata is the contract. They must not mutate the passed records to influence storage or authorization. They may throw `QException` to fail the operation. A failed registration must remain inaccessible through the policy until reconciliation succeeds; absence of a retained registry entry must never mean unrestricted access.
|
||||
|
||||
## Persistent retention
|
||||
|
||||
`collaboration_attachment.retained` is a required boolean with false default for existing ordinary attachments. Apply the generated PostgreSQL schema migration through the host's normal migration tooling before installing this version. The migration is `ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS retained BOOLEAN NOT NULL DEFAULT FALSE`. It does not automatically classify pre-existing files; any historical backfill requires a deliberate host migration.
|
||||
|
||||
There is no public API to clear retention. Direct user table writes remain denied. Deletion first authorizes and checks retention for the complete selection; it checks retention again immediately before storage deletion. Parent cleanup tombstones retained attachment rows and excludes completed tombstones from subsequent pages. The original bytes remain. The ordinary parent-based UI remains inaccessible after parent deletion; a retained-history view must supply its own preserved authorization policy. This extension does not grant archive access.
|
||||
|
||||
Removing the configured provider cannot restore deletion of rows already marked retained. The no-op provider also refuses signed URLs and metadata edits for those retained rows. Replacing a provider is a trusted host operation: the replacement must still enforce audit and quarantine for every row marked retained, even if its table is no longer configured for new retention.
|
||||
|
||||
Attachment DTOs expose separate `canEdit` and `canDelete` hints. Both require current ownership/admin and attach capability; retention disables deletion. Existing constructor overloads preserve source compatibility. The server rechecks all permissions on mutation. The old `canDelete` flag must no longer control client rename/description actions.
|
||||
|
||||
## Verified content for integrations
|
||||
|
||||
`AttachmentsService.readContent(attachmentId, maximumBytes)` authorizes the actual parent/current actor, enforces both content-read policy phases, validates stored length and SHA-256, closes storage on all paths and returns immutable `AttachmentContent(attachmentId, fileName, contentType, sha256, bytes)`. No storage coordinates or signed URL escape. The caller chooses a limit up to 20 MiB; ordinary large-file download remains a separate streaming/presigned path. No bytes leave this method before integrity verification and the result audit hook succeed.
|
||||
|
||||
`beforeContentRead(attachment)` must authorize and durably record the attempt before storage opens. `afterContentRead(attachment, bytesRead)` must record verified server-read completion before returning content. Both default to refusal for retained files until the lifecycle provider implements them. These facts are distinct from URL issuance, user download, printer spooling and physical output.
|
||||
|
||||
## Retryable intake
|
||||
|
||||
An upload overload adds `intakeKey` and `manifestSha256` after the ordinary upload arguments. Both are server-coordinator SHA-256 identifiers. The owner additionally hashes actual authenticated actor and canonical parent into the storage scope; never trust a client key as authority. The intake fingerprint includes the caller's manifest, filename, declared type, description, actual length and actual content hash. Every retry reads and checks its supplied bytes. A matching attachment is returned and its idempotent registration hook is retried; changed inputs conflict.
|
||||
|
||||
This path rejects ambient transactions. It conditionally creates a deterministic storage key with fingerprint metadata and never overwrites or deletes an uncertain original. If storage succeeded but metadata insertion failed, the next attempt verifies the existing bytes/fingerprint and completes registration. Host migration must add nullable `intake_key`/`intake_fingerprint` columns and the unique nonnull intake-key index from `CollaborationSchema`. That index is the database race boundary; sequential memory fixtures do not prove its concurrency behavior. The calling coordinator still owns durable request reservation, lifecycle and reconciliation scheduling.
|
||||
|
||||
Retryable files are bounded to 20 MiB and use a conditional single-object PUT. The inspected object-storage multipart implementation does not carry conditional-create semantics through completion; it is deliberately not used here. Larger retryable originals need a separately qualified conditional multipart path before this bound changes. This is an advertised intake limit, not a claim of unlimited file sizes or storage immutability. Real storage conditional behavior, object version/lock and backup retention still require provider acceptance.
|
||||
|
||||
## Atomic display metadata auditing
|
||||
|
||||
`beforeMetadataChange` records an independent attempt. Before the update, `validateMetadataTransaction(attachment, transaction)` validates the actual transaction and audit backend; retained files default to denial. After the update, `afterMetadataChange(attachment, changes, transaction)` inserts the success event into that same transaction and also defaults to denial for retained files. The provider must not commit or close the supplied transaction. Reject no-op transactions before mutation; memory tests only establish callback behavior.
|
||||
|
||||
When no caller transaction exists, Collaboration opens, commits, rolls back and closes the owner transaction. If a caller supplied a transaction, that caller owns completion and must roll back on any propagated failure. Successful operation and its event become durable together on commit. Record Files must qualify this with a real supported JDBC backend; this generic owner SPI cannot prove provider durability.
|
||||
|
||||
## Durability boundaries and remaining integration
|
||||
|
||||
The callback is not an audit database. Before returning from `beforeAccess`, a mandatory audit provider must have durably committed its intent independently of a caller transaction that may later roll back. An event means URL issuance was requested, not completed download or physical printing. Storage signing can still fail after the intent is recorded. Signed URLs can be reused until expiration; each use is not observed by Collaboration. Storage access telemetry or a serving gateway is required for transfer events.
|
||||
|
||||
Retained originals are never deleted when metadata insertion fails; ordinary non-retained failed inserts retain legacy cleanup. A committed STORAGE_WRITE_ATTEMPT can identify an orphan even when no attachment row was inserted.
|
||||
|
||||
An object put and QQQ metadata insert are not an atomic transaction. If a surrounding transaction rolls back after object creation, reconciliation must recover an orphaned object. The retained-only journal integration below provides explicit registration recovery; it does not provide malware scanning, WORM storage protection, bucket lifecycle protection or backup verification. Those remain deployment requirements.
|
||||
|
||||
QQQ record-reference write validation issues an internal GetAction using global metadata. Attachment previews use the existing `QueryOrGetInputInterface.getShouldGenerateDisplayValues()` contract: visibility filtering always runs, while URL generation runs only when display values were requested. Core validation GETs default to false; the standard middleware's display GET/query executors explicitly request true. This avoids incidental signing during validation without removing validators or modifying QQQ core. Trusted callers requesting presentation URLs directly through GetAction/QueryAction must explicitly enable display values; `AttachmentsService.downloadUrl` remains an explicit authorized access operation.
|
||||
|
||||
## Aggregate visibility policy
|
||||
|
||||
The optional configuration-only `requiresPinnedAggregateParent()` hook defaults to false. Owners whose parent visibility relies on dynamic archive/project authorization opt in. AttachmentAggregateGuard preserves the normal collaboration permission checker and additionally requires Count/Aggregate inputs to contain one literal top-level AND equality for both `tableName` and `recordId`. It checks current native parent visibility before SQL; joins, set-operation filters, OR roots, missing and conflicting pins are rejected. It does not grant view access.
|
||||
|
||||
Opt-in table metadata hides generic TABLE_COUNT and QUERY_STATS capabilities in both instance and table metadata. The current frontend's use-filter-setup hook already skips totals when TABLE_COUNT is absent and represents them as unknown; record listings and the attachment panel remain available. Native CountAction still permits an authorized pinned count. Do not substitute zero for an unavailable total or issue an unpinned aggregate and rely on a post-query filter: aggregate SQL cannot use that filter. Ordinary policies preserve the existing count capability. Verification is pending the new focused guard suite.
|
||||
|
||||
|
||||
## Retained upload journal integration (verification in progress)
|
||||
|
||||
Public attachment services always use JdbcUploadJournal for retained originals. Ordinary non-retained streaming behavior is unchanged. Apply UploadJournalTable.h2Script() as an owner migration before enabling this path: the immutable physical manifest includes private storage coordinates and lease tokens which deliberately are not QQQ metadata fields. Generating SQL from metadata alone is insufficient. Only shared file-H2 with actual WRITE_DELAY=0 is currently qualified; memory journals are explicitly injected test fixtures only.
|
||||
|
||||
Retained uploads are capped at20MiB and use conditional single-request creation. The journal commits the candidate, fences a lease, and records UNKNOWN before PUT. A conditional collision or uncertain response is accepted only after reading the bounded original and verifying actual length and SHA-256. Metadata insertion and journal REGISTERED checkpoint share one real owner transaction. The mandatory idempotent afterStored lifecycle callback runs after that commit; COMPLETE follows only after it succeeds. A failed callback leaves the same attachment id recoverable. Source archival refuses transfer while the owner checkpoint is incomplete.
|
||||
|
||||
Renewal occurs at synchronous stage boundaries. A storage call that outlives its lease cannot register under its expired fence; a newer recovery may inspect an existing matching original but never overwrite or delete it. No background task synthesizes or carries a browser session. Provider request deadlines and operational supervision remain host acceptance requirements.
|
||||
|
||||
The native Retained Upload Journal table exposes status to its original actor only, subject to current source visibility; after a completed archive transfer, visibility follows the exact attachment's current parent. Native totals are disabled and private-field predicate/order/unique-key/join probes are denied. The Recover retained upload action reauthorizes the original actor and current attach capability, verifies existing bytes and retries registration. It does not accept bytes, a locator, another actor or a coordinator scope. Coordinated intakeKey entries must retry through their original coordinator, preserving origin/version/provenance. The beforeRecovery(candidate) lifecycle hook is default-deny for retained originals and records a recovery read attempt separately from STORAGE_WRITE_ATTEMPT and user downloads.
|
||||
|
||||
Missing bytes or corrupt bytes remain unresolved, never timeout into cancellation or permission to delete possible evidence. A disabled/deleted original actor has no administrative impersonation escape; an explicitly audited trusted administrative reconciliation path remains to be designed. Existing attachment panels also need discoverable pending-upload recovery navigation; the standard table action alone is not a complete panel UX.
|
||||
|
||||
|
||||
### Recovery navigation metadata
|
||||
|
||||
Retention owners can call public UploadRecoveryNavigation.configure(sourceTable) after producing source metadata. It adds ATTACHMENTS.recoveryView containing the native owner table, label and actor/status filter. The generic Files panel adds literal canonical tableName/recordId AND criteria and navigates to the standard QQQ table and its recovery action. CurrentUser expressions are accepted only for createdByUserId/EQUALS with one value. Filters aid discovery and never replace original-actor/current-parent authorization. Missing or malformed metadata hides this optional navigation on ordinary installations. An interrupted-upload message does not claim saved/completed bytes; recovery still verifies the owner journal and exact object.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Standard attachment list pagination
|
||||
|
||||
The standard `GET /records/{table}/{pk}/attachments` endpoint now applies its
|
||||
page offset and limit inside the native QQQ attachment query. It reads at most
|
||||
`limit + 1` rows (maximum 101), projects the requested page, and uses the extra
|
||||
authorized row only to determine whether to emit the next cursor. It performs no
|
||||
count query and does not load the complete attachment list before slicing it.
|
||||
|
||||
The route, response schema, default limit 20, allowed limits 1–100, and existing
|
||||
version-1 scoped offset cursor bytes are unchanged. Sorting is still descending
|
||||
`createDate`, then descending `id`; equal timestamps therefore retain a stable tie
|
||||
order. These are offset pages, not snapshots: concurrent insertion or deletion can
|
||||
shift offsets. High offsets can require backend scanning; bounded returned rows
|
||||
and host allocation are not a constant-time database performance guarantee.
|
||||
|
||||
Each request still touches the current person, resolves and authorizes the actual
|
||||
source record, canonicalizes its primary key, excludes deleted-parent attachments,
|
||||
and uses the existing per-entry edit/delete policy. The same entry projection is
|
||||
evaluated for the lookahead row. There is no generic post-filter refill or inference
|
||||
from a denied row. Sources transferred to retained archive records use the exact
|
||||
current parent identity and its native authorization policy. This owner artifact
|
||||
does not import Record Files or reconstruct its archive policy.
|
||||
|
||||
Metadata visibility is separate from content release. Listing retained or
|
||||
quarantined metadata does not issue a download, preview or print capability; those
|
||||
paths retain their lifecycle and current authorization checks. The cursor is a
|
||||
routing/offset hint, not a signed authorization token. Current source access is
|
||||
required even when the caller presents a previously valid cursor.
|
||||
|
||||
`AttachmentsService.listPage` is the bounded public service operation. The older
|
||||
unpaged `list` method remains for compatibility; new page consumers should use
|
||||
`listPage`. Cursor parsing/encoding is shared with the existing complete-list
|
||||
pagination helper, so other Collaboration endpoints keep their previous behavior.
|
||||
|
||||
Verification: local focused run `target/attachment-pagination-focused.log` passed
|
||||
40 tests on 2026-10-31 at 02:01:44, with zero failures/errors/skips and Checkstyle.
|
||||
Two new tests execute the actual QQQ RDBMS action against H2 and observe native
|
||||
offset/limit and returned-row bounds, equal timestamp ordering, deleted/other
|
||||
source exclusion, retained flags, terminal pages and current revocation before
|
||||
attachment SQL. The other 38 tests cover existing attachment service behavior and
|
||||
the unchanged OpenAPI fixture. Root HTTP run47446 also passed all12 tests, including
|
||||
the new regression covering three pages,
|
||||
cursor scope, malformed/oversized input and a denied actor. Cross-qbit archive integration, full release verification and installation
|
||||
of this new owner snapshot remain separate gates. No installed or frozen consumer
|
||||
artifact has been replaced by this change.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Attachment cleanup recovery
|
||||
|
||||
Deletion writes a standard `collaboration_attachment_cleanup` row and removes attachment metadata in the same backend transaction. Only after commit does it delete the stored object, then acknowledge the cleanup row. PostgreSQL rejection of metadata deletion leaves both the original row and bytes intact. A provider failure or lost callback leaves the cleanup locator available after restart. The service returns a failure when immediate cleanup fails; the logical attachment deletion may already have committed.
|
||||
|
||||
The cleanup table is visible in the Collaboration app to administrators. Direct table mutations are denied, including to administrators, so callers cannot forge deletion targets. Recovery runs through `collaborationRetryAttachmentCleanup`, guarded for system sessions or `collaboration.admin`. With the host's configured scheduler, it retries a bounded batch of 100 every minute. Kof22 already supplies a scheduler name. Without a scheduler, operators must invoke the recovery process; it is not safe to assume background retries exist.
|
||||
|
||||
Apply the generated PostgreSQL schema before enabling this version; it creates the additional cleanup table and due-time index. Attachment ID is optional because rejected uploads may never have a committed ID. The upgrade preserves existing cleanup rows and removes the earlier attachment-ID uniqueness constraint. Do not remove that table during application rollback while cleanup remains pending. Existing releases do not know how to drain it.
|
||||
|
||||
Verification covers real PostgreSQL metadata rejection, outer upload/delete rollback, commit, provider failure followed by a new recovery service, loss of post-commit callbacks, failed queue acknowledgement and repeat replay. Deterministic storage faults also cover a provider that writes bytes before reporting upload failure and two concurrent cleanup workers. A real MinIO fixture, accessed through the published object-storage QBit, verifies deletion replay in plain and versioned buckets while preserving another object's exact bytes. This is not evidence for every object-storage provider. The separate abrupt-JVM test described below uses fixture file storage.
|
||||
|
||||
Failed upload rollback compensation now records its locator independently of the rolled-back transaction before attempting storage cleanup. Failed worker attempts persist a next-attempt time at least 60 seconds later; this lets healthy requests beyond a failing batch proceed. Tests verify later recovery once the failed requests become due.
|
||||
|
||||
Upload I/O and runtime failures attempt durable compensation and preserve the original exception, adding cleanup failure as a suppressed exception. Deferred provider completion is covered by the settlement protocol below.
|
||||
|
||||
Full Maven verification including operator settlement passes 206 tests, zero failures, errors or skips, plus packaging, help coverage, architecture guardrails and JaCoCo checks. PostgreSQL tests cover the retained receipt, repeated requests, active owners, existing references, rejected receipt writes, provider failure and the real form/Review/Result flow with permission revocation. Coordinated host adoption and staging rollback still require separate verification.
|
||||
|
||||
On backends supporting transactional FOR UPDATE SKIP LOCKED (including the PostgreSQL host), upload now commits a cleanup locator before calling storage and holds its row lock for the upload transaction. Metadata insertion and removal of that locator share the same transaction. Recovery workers skip active uploads and hold the same lock while deleting abandoned bytes and acknowledging cleanup. An existing caller transaction retains ownership until it ends; otherwise the service starts its own transaction after spooling the input. Backends without this locking capability retain callback-based compensation and are not covered by this ownership guarantee.
|
||||
|
||||
Focused PostgreSQL tests verify the locator exists before provider I/O, cleanup skips an active upload, and recovery succeeds after an interrupted upload whose rollback callbacks were deliberately suppressed. That test simulates callback loss. A separate child JVM executes the actual upload service against the disposable PostgreSQL database, persists fixture bytes, then calls Runtime.halt before returning from the provider. The parent verifies exit code 73, absent metadata, retained intent and bytes, and successful idempotent recovery using the original locator. This proves abrupt JVM termination recovery, not power-loss durability or late remote-provider completion. Another test rejects intent insertion in PostgreSQL and verifies zero provider writes or deletes; a completed upload in an open outer transaction remains protected from cleanup until commit. Concurrent workers verify that only one worker enters provider deletion for a locator.
|
||||
|
||||
Issue #1289 remains open for coordinated host adoption and application rollback acceptance. Row locking does not make external object storage and the database one atomic transaction.
|
||||
|
||||
## Unreleased late-upload settlement change
|
||||
|
||||
A PostgreSQL regression reproduced a PUT finishing after timeout rollback had deleted the still-empty location and acknowledged its cleanup row. Upload intents (no attachment ID) now use AttachmentStorage.cleanupUpload, which removes current bytes and reports whether an earlier PUT can still complete. The generic adapter cannot prove settlement and returns false. Recovery retains the locator for later sweeps, with the existing 60-second backoff and 100-row batch. Ordinary committed deletion and successful upload acknowledgement remain unchanged.
|
||||
|
||||
Adapters may report settlement only from authoritative completion/cancellation information, never merely object absence. A deterministic deferred-write fixture verifies the late-completion ordering; this does not establish remote S3 settlement. Generic uncertain-upload records remain pending until the provider proves settlement or an administrator performs the explicit operation below. There is no age-based expiry.
|
||||
|
||||
## Resolve a settled upload
|
||||
|
||||
In the Attachment Cleanup table, select upload intents and run **Resolve settled uploads**. An identified user with `collaboration.admin` must supply provider completion or cancellation evidence covering every selected locator, then confirm the standard Review screen. A support case or request receipt establishing completion is suitable; object absence, an elapsed timeout, or an application process stopping does not establish that an external provider has stopped writing. If settlement cannot be established, leave the intent pending.
|
||||
|
||||
Apply rechecks permission, locks each row, rejects active ownership and existing attachment references, and deletes current bytes before saving completion. Provider or database failures leave the intent unresolved for retry. Committed attachment deletions use the ordinary cleanup retry operation instead.
|
||||
|
||||
The service-managed row retains `resolvedAt`, `resolvedBy` and `settlementEvidence`, together with the original locator. Repeated settlement does not overwrite that receipt; scheduled retries exclude resolved rows. Direct edits and deletes remain denied even to administrators. These fields provide the durable operational receipt without relying on the framework's best-effort audit writer. Store a reference to provider evidence, never credentials or private object contents.
|
||||
|
||||
Apply the additive settlement columns and pending-row index before this code runs. Kof22's corresponding migration is `2026-10-26-collaboration-upload-settlement.sql`. Retain receipts and pending intents during rollback. Do not start an earlier cleanup worker that ignores `resolvedAt`: it could consume retained receipts. Roll back to the pre-cleanup application with cleanup processing disabled, or use a version that understands settlement receipts.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Coordinated collaboration snapshot
|
||||
|
||||
## Combined integration follow-up under qualification
|
||||
|
||||
PR #51 combines current attachment settlement, My Work, presence recovery,
|
||||
contributed-field and capacity follow-ups. Its source inventory and gate history
|
||||
are in [INTEGRATION-20261029.md](INTEGRATION-20261029.md). The candidate selects
|
||||
verified published Redis `0.1.0-redis-integration-20261029-551f1d5-SNAPSHOT` and
|
||||
the existing core `4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT`.
|
||||
The combined collaboration artifact is not yet published. Historical snapshot
|
||||
and acceptance evidence below is not proof of this candidate's deployment.
|
||||
|
||||
## Selectable-field follow-up snapshot
|
||||
|
||||
The following focused upgrade is published for QQQ #1196:
|
||||
|
||||
| Component | Version |
|
||||
| --- | --- |
|
||||
| QQQ BOM and modules | `4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT` |
|
||||
| qbit-collaboration | `0.1.0-field-values-1196-ecff347-SNAPSHOT` |
|
||||
| qbit-redis (unchanged prerequisite) | `0.1.0-redis-7fe5492-SNAPSHOT` |
|
||||
|
||||
Import this QQQ BOM in the consuming host. Collaboration publisher 168 passed
|
||||
from `ecff347efe2bb84949b8452a3383e3fdeb4bcede`; its POM and JAR resolve to
|
||||
timestamp `20261026.133203-1`. Full collaboration verification against the
|
||||
published core passed 198 tests with no failures, errors or skips, plus packaging
|
||||
and coverage gates. An independent empty-cache Java 21 consumer downloaded the
|
||||
pair and compiled both collaboration value providers against the core API.
|
||||
See [the upgrade evidence](VIRTUAL-FIELD-UPGRADE.md) for checksums and scope.
|
||||
|
||||
This is not a combined release of the separate attachment, My Work and presence
|
||||
recovery follow-ups, nor a Kof22 deployment. The earlier candidate's browser
|
||||
acceptance below must not be treated as browser qualification of this new pair.
|
||||
A compatible packaged frontend and host staging qualification remain required.
|
||||
|
||||
## Earlier main-line candidate
|
||||
|
||||
The earlier main-line candidate is published. Its coordinated versions are:
|
||||
|
||||
| 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,9 @@
|
||||
# Dependent possible values
|
||||
|
||||
QQQ 4.1.0-RC.5 has no `dependsOn` field property. Each pair has a TODO to add it when core ships it; automatic refresh and clearing stale selections remain frontend responsibilities.
|
||||
|
||||
- `collaboration_comment.parentCommentId` reuses the `collaboration_comment` TABLE source. Its field filter is `threadId EQUALS ${input.threadId} OR id EQUALS ${input.threadId}`: roots have null `threadId`, replies carry the root ID. The OR is nested inside an outer AND because core appends search criteria to the supplied filter. This lists the root and replies of just that thread; comment posting still normalizes a selected reply to its root.
|
||||
- `collaboration_feature_setting.scopeName` uses `collaboration_scope_name`, a CUSTOM source driven by `otherValues.scopeType`. APP offers app names and labels. TABLE excludes `collaboration_*`, hidden tables and tables without a valid primary-key field (RC.5 metadata has one primary-key field). INSTANCE offers `instance`, since scopeName is required but the current TAGS settings reader ignores it for INSTANCE. General runtime override contracts remain deferred. Bare-ID translation prefers an app label before a table label if their names collide; searches use the selected scope's label.
|
||||
- `collaboration_comment.fieldName` uses reusable CUSTOM source `collaboration_record_field_name`. It resolves `otherValues.tableName`, or reads a comment via `commentId`/`id` when tableName is absent. Other forms should supply tableName (or explicit commentId); bare id fallback is for comment forms. Fields come from permission-enforced personalized metadata, excluding hidden fields, and display `Label (name)`. Bare-ID translation retains the name because it has no table context.
|
||||
|
||||
The table possible-values middleware clones the field filter and calls `interpretValues` with the `input` map and FORM use case before passing it as `defaultQueryFilter` to `SearchPossibleValueSourceAction`. Tests mirror that boundary and pass `otherValues` to the core action. CUSTOM providers receive that map directly; searches honor explicit IDs first, otherwise case-insensitive name/label substring matching.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Core dependency: after-rollback callbacks
|
||||
|
||||
Collaboration's retained-upload and cleanup paths compensate external object storage only after a transaction's rollback is confirmed. They need two methods on `com.kingsrook.qqq.backend.core.actions.QBackendTransaction`. These are included in QQQ Core `4.1.0-RC.22`, which this module imports through the BOM; Core `4.1.0-RC.21` does not declare them.
|
||||
|
||||
## Required API (reviewed implementation: `b6fb2382`, `third-party-src/qqq-core`)
|
||||
|
||||
`qqq-backend-core`, class `QBackendTransaction`:
|
||||
|
||||
```java
|
||||
public boolean supportsRollbackCallbacks() // default: false
|
||||
public void addAfterRollbackCallback(Runnable callback) // throws UnsupportedOperationException unless supportsRollbackCallbacks(); Objects.requireNonNull(callback)
|
||||
protected void runAfterRollbackCallbacks() // run once, in order, then clear; exceptions (incl. LinkageError) logged and swallowed
|
||||
```
|
||||
|
||||
Pending rollback callbacks are preserved when a required operation marks the transaction rollback-only, and run only after confirmed JDBC rollback. A failed physical commit clears them because its outcome is uncertain. Successful commit discards them.
|
||||
|
||||
`qqq-backend-module-rdbms`, class `RDBMSTransaction`:
|
||||
|
||||
```java
|
||||
@Override public boolean supportsRollbackCallbacks() // true
|
||||
@Override public void addAfterRollbackCallback(Runnable callback) // super.add...; then rolledBackSinceCommit = false
|
||||
// rollback()/close(): after connection.rollback() succeeds -> rolledBackSinceCommit = true; runAfterRollbackCallbacks()
|
||||
// close() rolls back (and so runs callbacks) unless a rollback already happened since the last commit / callback registration
|
||||
```
|
||||
|
||||
## Callers in this module
|
||||
- `attachments/AttachmentsService.storeUpload`: refuses an ambient transaction where `!supportsRollbackCallbacks()`; registers storage compensation.
|
||||
- `attachments/AttachmentCleanupQueue.beginUpload`: registers the same cleanup on both `addAfterRollbackCallback` and `addAfterCommitCallback`.
|
||||
|
||||
## Core tests to carry with the change
|
||||
- Base class: `supportsRollbackCallbacks()` is false; `addAfterRollbackCallback` throws `UnsupportedOperationException`.
|
||||
- RDBMS: callback runs after `rollback()`; does not run after `commit()`; runs when a transaction with a registered callback is closed without commit;
|
||||
a callback registered after an earlier rollback still runs on close; a throwing callback does not stop later callbacks.
|
||||
- Integration (already in this module, needs the core): `attachments/AttachmentTransactionsPostgresTest` (needs Docker) incl. `interruptedUploadSurvivesLostRollbackCallback`.
|
||||
+139
-5
@@ -2,11 +2,11 @@
|
||||
|
||||
The authoritative design is [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md` ([develop document](https://github.com/QRun-IO/qqq/blob/develop/docs/COLLABORATION.md)).
|
||||
|
||||
Collaboration adds metadata-driven record features to QQQ: attachments, tags, custom fields, comments and mentions, presence, sharing, favorites, follow, timeline, recents, tasks and assignment. Core owns security, query and runtime metadata contracts; this qbit owns visible `collaboration_*` tables and feature services. Implemented: phase-a configuration, feature settings and people tables, plus phase-b favorites/recents tables, services, owner locks, retention and home counts against QQQ 4.1.0-RC.5. The phase-b UI API endpoints await the released extension point from QRun-IO/qqq#1128. The override and record-feature summary providers await released core contracts; phase-c attachments are implemented on object storage 0.1.0-RC.2; phases d to i remain planned. The table below is the full roadmap from the specification, not a list of shipped functionality.
|
||||
Collaboration supplies visible `collaboration_*` tables, feature services and 42 typed UI API operations. QQQ `4.1.0-SNAPSHOT` (target RC.6) owns feature resolution, permission registration, reference security, mirrored locks, virtual criteria, record summaries and realtime transport. Object storage remains `0.1.0-RC.2`.
|
||||
|
||||
| Phase | Core | Qbit | Next UI |
|
||||
|---|---|---|---|
|
||||
| a. Contracts | `RecordFeaturesMetaData` + resolver + frontend publication; `RecordReference`, `RecordReferenceTableMetaData` and the security rules of §3.2 (table filter, mirrored locks, parent check, post-filter, cleanup hook); feature permissions in the catalog; `RecordFeatureSummaryProviderInterface` and `GET /table/{t}/{pk}/features`; `RuntimeMetaDataProviderInterface` and overlay (generation only, no fields yet); `RecordAccessCheckerInterface`, `UserSessionProviderInterface` | repo scaffold, qbit config, `collaboration_feature_setting`, `collaboration_user`, override provider, home skeleton | types for `RecordFeaturesV1` |
|
||||
| a. Contracts | `RecordFeaturesMetaData` + resolver + frontend publication; `RecordReference`, `RecordReferenceTableMetaData` and the security rules of §3.2 (table filter, mirrored locks, parent check, post-filter, cleanup hook); feature permissions in the catalog; `RecordFeatureSummaryProviderInterface` and `GET /table/{t}/{pk}/features`; `RuntimeMetaDataProviderInterface` and overlay (generation only, no fields yet); `RecordAccessCheckerInterface`, `UserSessionProviderInterface` | repo scaffold, qbit config, `collaboration_feature_setting`, `collaboration_user`, override provider, home skeleton | types for `RecordFeaturesV1` |
|
||||
| b. Favorites, recents, palette | | favorites, recents, endpoints, summary provider | star in record and table headers, favorites menu, palette favorites group, server recents |
|
||||
| c. Attachments | | attachments with object storage, previews and thumbnails, delete action | Files tab, upload, preview |
|
||||
| d. Tags | `QSubQuery`; virtual field criteria expander in query, count, aggregate and search; `CONTAINS_ALL`; `#tag` search | tags, vocabulary modes, bulk actions, filter field | tag chips, Tags filter and column, `#tag` in palette and search |
|
||||
@@ -19,12 +19,146 @@ Collaboration adds metadata-driven record features to QQQ: attachments, tags, cu
|
||||
|
||||
## Phase-c implementation choices
|
||||
|
||||
- `ParentRecordAccess` is the named replacement point for the unreleased core helper. GetAction enforces parent READ and locks. Attach is an explicit feature grant or EDIT plus visible parent; separate WRITE locks remain an approximation.
|
||||
- `ParentRecordAccess` delegates to core visibility and feature capabilities. Attach requires the core feature grant or implied EDIT with parent WRITE locks.
|
||||
- Admin browsing requires collaboration.view, while panel service calls authorize the parent directly. Direct table writes are denied. Internal persistence uses explicit per-action permission bypasses after service checks, and per-input metadata clones without preview customizers. The caller's session is never elevated or replaced. Cloning also lets delete prefetch find rows after the parent is deleted, while preserving FIELD audits and record-change listeners.
|
||||
- RC.5 offers no PRE_QUERY customizer. The attachment permission checker adds a readable-table IN filter before query/count/aggregate; POST_QUERY filters parent locks before presigning. Approximate counts/bytes and short pages are documented. Full mirrored locks, page refill and API approximate flags await core.
|
||||
- Core adds readable-table/feature restrictions before query/count/aggregate and enforces configured mirrored locks. The retained POST_QUERY fallback protects unmirrored parent locks before presigning. Page refill and exact unpinned counts remain an upstream limitation.
|
||||
- Uploads spool to bounded temporary disk while hashing; no full-file heap buffer. The bucket cap also applies. Keys contain a UUID and safe basename; renames do not move bytes. Object/row writes are ordered with best-effort compensation, not a distributed transaction.
|
||||
- The object-storage adapter uses the calling-qbit system facade only after authorization. Soft deletion follows bucket policy. Object retention failures leave retryable rows. In-memory adapter tests exercise QQQ actions and security; provider conformance stays with object storage's MinIO suite.
|
||||
- Previews/downloads are virtual fields in the RECORD_VIEW alternative section (RC.5 disallows virtual fields in base sections). Five-minute URLs are never stored. RC.2 supports attachment filename overrides, but not explicit inline overrides; uploads carry an inline header. There is no thumbnail generator or generated thumbnail file.
|
||||
- Delete attachments uses standard streamed ETL. Attach files is omitted for API/panel uploads. Cleanup is a trusted public after-commit callback pending RecordReferenceCleanupInterface. Endpoints await #1128; runtime feature settings, activity and realtime publication are future integrations. Native search fields are present; ESB and Quick Search publication are host integrations.
|
||||
- Delete attachments uses standard streamed ETL. Attach files is omitted for API/panel uploads. Cleanup implements RecordReferenceCleanupInterface. Typed endpoints, runtime settings and realtime publication are integrated; attachment services now write activity. Native search fields are present; ESB and Quick Search publication are host integrations.
|
||||
|
||||
See [README](../README.md#attachments) for the service contract, permissions and configuration.
|
||||
|
||||
|
||||
## Phase-e implementation choices
|
||||
|
||||
- **Released core only.** RC.5 includes record-change listeners and transaction after-commit callbacks. The producer registers a listener only when `followChangeTables` is nonempty. Notification delivery uses the context transaction; temporary recipient contexts never inherit the caller's permission-bypass scope. The notifier remains an adapter for #1125; recipient access implements core access/session contracts.
|
||||
- **Security at both boundaries.** Services check the real parent before disclosing any row. Direct table writes are denied even to admins. Core reference metadata adds parent-table and feature restrictions; `ParentRowCustomizer` checks record locks on fetched rows. Revision and mention rows mirror their comment's parent reference to use the same pre-query restriction. Configured mirrored locks and runtime resolution use core; unpinned post-filtering/page refill remain incomplete upstream; counts and unpinned page lengths have the same limits as attachments.
|
||||
- **Conversations are persisted relationships.** Roots have null thread IDs; replies flatten to a root. `lastActivityAt` is maintained on roots to support indexed keyset paging. Revisions retain the old body. Mention uniqueness prevents re-notification on subsequent edits. Visible mentions auto-follow; unknown is never a grant. Notifications deduplicate mentions versus followers and recheck access after commit. The default no-op transport does not mark mentions delivered.
|
||||
- **Durable follow batches.** The additional standard `collaboration_follow_digest` table stores one pending batch per parent/recipient. Due time is fixed at the first change plus five minutes. Record-change listeners copy event data and queue only after commit; batches contain field/actor summaries, never old/new values. Delivery rechecks current follow preferences and recipient access. One scheduled sender per installation is required; local synchronization and a unique key are not a cross-pod lease. Delivery/delete is at-least-once across a process crash; adapter idempotency is needed for stronger delivery guarantees.
|
||||
- **Audit reads preserve the user's authority.** Timeline uses core's released audit table names/fields with ENFORCE on every query, then applies core table personalization to field details. Headers are queried separately from details so a joined-row limit cannot cut an event in half. Denied audits are omitted. Core stores audit actor names without stable session user IDs; the DTO leaves those IDs null. Both streams are bounded and merged by descending `(at, kind, id)`. Cursor IDs are compared as numeric strings because RC.5's memory equality distinguishes Integer and Long values.
|
||||
- **Directory throttling is persistent.** Ten-minute last-seen checks survive service recreation and rollback without maintaining an in-memory cache. QUser supplies full name/ID only; email/avatar enrichment stays with trusted host integrations. Mention ranking queries participants first so the limit cannot hide an existing follower behind many other users.
|
||||
- **User-facing metadata.** Comments and admin-only Activity are main nav tables. Revisions, mentions and Followers are child lists; digest/follow tables are admin-browsable with HIDDEN deny behavior. Follow actions use generic streamed ETL Review/Result against any host key. The home reads real session-scoped comments/open-thread/follower counts. Standard QQQ writes remain available to host ESB listeners.
|
||||
|
||||
The service API and configuration are documented in [README](../README.md#comments-follow-and-timeline). Endpoints use the core extension API; overlay, mirrored locks and realtime use the local core snapshot.
|
||||
|
||||
## Phase-d implementation choices
|
||||
|
||||
- **Core query boundary.** `TagCriteriaExpander` returns core subqueries over record tags, preserving AND/OR and complement semantics. Runtime metadata adds the Tags field and search entry where enabled. TEXT matches labels and TAG matches normalized names. Selectable values await a core virtual-field output hook.
|
||||
- **Vocabulary versus assignments.** Tag names are unique per nullable scope;
|
||||
PostgreSQL DDL emits NULLS NOT DISTINCT for nullable unique-key components.
|
||||
Record Tags uses core reference metadata and the unpinned parent row fallback,
|
||||
including the existing approximation for aggregate counts and short pages.
|
||||
Vocabulary pickers need no collaboration.view grant: ordinary users see
|
||||
approved tags and their own suggestions in readable scopes, while manage sees
|
||||
all vocabulary for moderation. Direct writes are denied; services/actions
|
||||
preserve normalization, approval and counts.
|
||||
- **Policy lookup.** Settings are read in a temporary system context, retaining
|
||||
the transaction and restoring the caller. TABLE overrides preferred APP (QQQ's
|
||||
app-affinity path), then INSTANCE, then `defaultTagMode`. Missing mode inherits;
|
||||
malformed JSON/invalid mode throws. General enabled/permission-mode runtime
|
||||
overrides remain outside this phase. Name collision resolution prefers global
|
||||
vocabulary; IDs provide an unambiguous selection.
|
||||
- **Mutation boundaries.** Like existing services, writes participate in the
|
||||
caller's QQQ transaction and serialize within the installed config's monitor.
|
||||
Unique keys enforce assignment identity. Counts are recomputed from stored
|
||||
links, including merge deduplication, deletion and trusted parent cleanup.
|
||||
This is not a cross-pod lock: concurrent writers in separate instances need
|
||||
host transaction/serialization coordination for exact denormalized counts.
|
||||
Standard ETL runs with its process transaction. Activity is generated only for
|
||||
actual apply/remove changes and never calls follower notification services.
|
||||
- **Review and Result.** Actions derive operation and table from installed process
|
||||
metadata. Management requires collaboration.tags.manage at both stages. Bulk
|
||||
actions resolve approved PVS selections and recheck every parent and feature
|
||||
permission at both stages. Review is read-only, counts already-satisfied rows,
|
||||
and Delete reports distinct affected parents. Result reports successful and
|
||||
failed records separately. The picker exposes multiSelectFields on EDIT_FORM
|
||||
for the separate Next client; RC.5 itself has no native multi-value form field.
|
||||
- **Presentation.** Tags is in the admin app nav; Record Tags is a child widget.
|
||||
The home queries under the caller session and HTML-escapes labels. Native tag
|
||||
label search and host-record tag search use the core virtual-field expander.
|
||||
|
||||
|
||||
## Phases h/i implementation choices
|
||||
|
||||
- Shares, tasks and assignments reuse parent checks, extension restrictions and FIELD audits.
|
||||
Access requests use requester/admin locks, no parent read, and metadata-only table labels.
|
||||
Trusted persistence removes only the request owner lock on a private metadata clone after
|
||||
explicit service authorization. Standard table actions retain that lock; audit keeps the caller.
|
||||
- Previews retain true/false/unknown. Unknown never enables record-data delivery or following.
|
||||
Safe access notices use after-commit delivery without a parent check and omit record labels,
|
||||
sender messages and approver notes. Record-data notifications recheck access at delivery.
|
||||
- OPEN request identity is `(tableName, recordId, requesterUserId)`. Local serialization and a
|
||||
PostgreSQL partial unique index protect it; insert collisions reread the existing row. No
|
||||
parent query distinguishes existing from missing records. Permission-holder resolution is an
|
||||
optional host SPI, with the qbit record owner as fallback. TABLE-scope SHARING settings supply
|
||||
the approver permission; native ownership integration awaits the released core phase-i API.
|
||||
- Streamed actions choose table/operation from installed metadata, validate in Review and again
|
||||
at load, and report Result counts. Configured approvers use an authorized request extractor
|
||||
without widening ordinary table reads. Grant records an identity-system outcome only.
|
||||
- Tasks default to the creator, compute reminder times using an injectable Clock, and keep
|
||||
independent reminder/overdue delivery receipts. Creation/completion also enter the existing
|
||||
follow digest. Run one scheduler per installation; delivery then receipt has the same crash
|
||||
retry/idempotency limitation as follow digests. Unknown access and no-op delivery leave no receipt.
|
||||
- Assignment is a qbit fallback. Hosts with owner/assignee columns use core RecordOwnership and
|
||||
ordinary updates, not collaboration_record_assignment or its configured actions. Bulk actions
|
||||
preserve the other role. Virtual fields and CurrentUser integration belong to core phase i.
|
||||
- My work/my-records counts recheck parents, removing invisible content and counts. RC.5 candidate
|
||||
rows are materialized for portable null-last sorting/counting. Home aggregates retain the
|
||||
existing documented parent-lock approximation until core mirrored locks are released.
|
||||
- The producer registers a host DELETE listener with after-commit cleanup. Access requests
|
||||
survive deletion. Schema SQL includes partial request/reminder indexes and a readAt migration
|
||||
for the existing mention table. Task title search metadata is present; ESB/Quick Search
|
||||
publication remains a host integration.
|
||||
|
||||
See the README for service signatures, defaults, configuration and the exact access-request
|
||||
prefill route. UI API endpoints and screens follow in their separate PR.
|
||||
|
||||
|
||||
## RC.6 integration boundaries
|
||||
|
||||
`CoreCollaboration` installs configuration defaults, feature-setting overrides, runtime Tags metadata, summaries and cleanup providers. Core enrichment registers feature permissions. `ParentRecordAccess` is a canonical-key wrapper around core visibility and record capability checks, including WRITE locks for implied grants. Every extension reference table declares core metadata; revision/mention rows already carry denormalized references alongside their comment joins. Author restrictions apply where appropriate; tags and tasks retain their collaborative service rules.
|
||||
|
||||
Core still lacks unpinned row post-filter/refill support for unmirrored locks. Retained row/attachment filters protect that gap and redact tombstones; projected reads include the reference fields needed by these filters. Counts remain approximate. Mirrors default to none and generated DDL includes ALTER statements when configured. Cleanup uses private metadata without reference checks because the parent is gone. Share acknowledgement is a separate, recipient-verified exception that changes only receipt time.
|
||||
|
||||
Feature writes refresh the overlay after commit. Initial loading follows core's startup contract: refresh after enrichment and database setup. The Tags contribution resolves incoming policy before core publishes its new snapshot. No selectable virtual values are advertised, because the overlay has no output hook. The installed core jar also lacks the optional followers SPI.
|
||||
|
||||
`CollaborationWorkSchemas` and endpoint specs expose sharing, access requests, tasks, assignment and My work; the README and generated OpenAPI contain the route list. Task PATCH validates before writing and preserves omitted fields. Access requests never inspect parent existence. Contract version remains unreleased 1.0.0.
|
||||
|
||||
`CollaborationRealtime` merges stored references for partial updates, captures immutable identifier payloads and publishes after commit. Notification hints follow actual delivery. Rollback discards callbacks. Bus failure is best-effort and does not fail a committed write.
|
||||
|
||||
|
||||
## Custom fields and presence
|
||||
|
||||
`CustomFieldsProvider` contributes ACTIVE definitions through core's runtime overlay. Tags and
|
||||
custom fields share incoming feature-policy resolution, so a persisted setting takes effect in
|
||||
the same refresh. FIELD-audited definitions enforce management permissions and lifecycle rules
|
||||
on both direct writes and streamed actions. Numeric range behavior is added only when a bound
|
||||
exists. Classification stays in core AiFieldMetaData. Select option ids are stable across label
|
||||
changes, with parameterized PostgreSQL containment checks before removing active options.
|
||||
|
||||
Storage availability is cached per instance/table. Memory needs no schema; PostgreSQL uses a
|
||||
zero-row probe through the backend connection provider. Failure installs a visible read-only
|
||||
EAV filter instead of a broken JSON field. Typed subqueries retain parent permissions/locks;
|
||||
EAV values are neither projected nor written by this PR. The child table declares core reference
|
||||
security and participates in parent cleanup. MULTI_SELECT is a JSON-array scalar string until
|
||||
core adds native array storage/filter contracts.
|
||||
|
||||
Index building is separate from overlay construction. Concurrent DDL runs through independent
|
||||
autocommit connections and records its statement/state/error on the definition. A no-DDL option
|
||||
records migration statements only. No column/table DDL runs during refresh. JDBC-boundary tests
|
||||
cover probe caching, option containment, autocommit and failure reporting. Live PostgreSQL
|
||||
acceptance remains necessary for host index expressions, especially temporal casts.
|
||||
|
||||
Presence is the only non-table state. Pods subscribe at initial overlay refresh and keep bounded
|
||||
user/connection/target leases. `instance:presence` is denied by core's client channel authorizer.
|
||||
State mutation and bus publication occur outside each other's monitor, preventing deadlock on
|
||||
synchronous bus fan-out. Wire data uses transport-safe maps. Record focus stays on record
|
||||
channels; table events omit pk and fieldName. Each HTTP request rechecks parent visibility and
|
||||
PRESENCE; session identity is authoritative and the directory is never touched. Hosts close the
|
||||
registry before the core bus.
|
||||
|
||||
Limits: EAV writes/projection, native multi-select operations, automatic cross-pod metadata
|
||||
refresh, replacing existing index definitions, and live PostgreSQL acceptance are not provided.
|
||||
The no-DDL option does not infer whether an external migration has run. Presence relies on a
|
||||
trusted instance bus and best-effort delivery; renewed heartbeats restore state after outages.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Coordinated collaboration integration candidate
|
||||
|
||||
This branch combines the current heads of the attachment, My Work, presence
|
||||
recovery, virtual-field and capacity PRs. It is a qualification candidate, not
|
||||
a published release or a deployed host.
|
||||
|
||||
| PR | Included source |
|
||||
| --- | --- |
|
||||
| #45 attachment settlement | `dc65a826ce362416d55d91136fa39225f4a7cf94` |
|
||||
| #46 bounded My Work | `cdcf7ee36509c7a1fc6f22fa864209eb80a6ff34` |
|
||||
| #47 presence recovery | `776ebe3fcb6492881bc9d7ec3f6ca6adfa320200` |
|
||||
| #48 contributed field values | `1da538cf43ba1ed80e3f8fd599729b5691563cbf` |
|
||||
| #49 capacity qualification | `56a8b760d9c1a1862cb11e80fad07cf5cdc20b3a` |
|
||||
|
||||
All five heads are ancestors of integration commit
|
||||
`44a89d77b1ad3a39d26c512ada78af0884872013`. The stacked My Work and capacity
|
||||
branches did not contain the latest heads of their base PRs, so those base
|
||||
heads were merged explicitly as well. No production source was rewritten during
|
||||
integration. The core prerequisite remains
|
||||
`4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT`. The candidate now selects
|
||||
published Redis `0.1.0-redis-integration-20261029-551f1d5-SNAPSHOT` after native
|
||||
publisher job42 succeeded and independent timestamped POM/JAR downloads matched
|
||||
their declared version and repository checksums. The timestamp is
|
||||
`20261029.165102-1`; JAR SHA256 is
|
||||
`385f69c5d84362dbab40a3044261fb9820e25126785d7f22901121302e6ce076`.
|
||||
|
||||
The caller and manifest both select released `kingsrook/qqq-orb@0.8`. The
|
||||
canonical validator extracted from released 0.8.32 and CircleCI schema validation
|
||||
pass; no unpublished orb changes are used.
|
||||
|
||||
Local `./mvnw.cmd verify -Dqqq.mywork.load=true` ran 238 tests with no skips, but
|
||||
reported three failures and four errors. Failures include Redis command read
|
||||
timeouts/rejoin assertions and PostgreSQL connection I/O during the attachment
|
||||
fixture. Docker Desktop's published-port path has independently exhibited
|
||||
300–900 ms replies after idle, compared with 0–58 ms on a direct Docker network.
|
||||
This observation is not proof of every failure's cause. No timeout, assertion,
|
||||
security boundary, or quality gate was weakened. Native hosted Linux results
|
||||
are required before publication; the local run is **not** a passing gate.
|
||||
|
||||
Native CircleCI job192 subsequently passed the full verification at integration
|
||||
head `02085baff928c374e3b0d77367fbcc8182f81f93`; CodeQL also passed. Those checks
|
||||
used the earlier Redis pin. The new published Redis pin above requires its own
|
||||
current-head native verification before this collaboration artifact is published.
|
||||
Use an explicit short feature branch (`feature/collab-int-20261029`) and dry-run
|
||||
the released version calculator: generated help-bundle versions must remain at
|
||||
most 50 characters. The longer `collaboration-integration-20261029` name would
|
||||
produce 57 characters and is not a valid publication route.
|
||||
|
||||
The successful earlier combined-source checks remain historical evidence only.
|
||||
Host TLS/ACL, actual identity/proxy/provider acceptance, maintenance/restore
|
||||
enforcement, artifact publication, and deployment are separate gates.
|
||||
@@ -0,0 +1,92 @@
|
||||
# My Work summary query bounds
|
||||
|
||||
The summary previously materialized every matching task, assignment and mention.
|
||||
A regression fixture measured 601 parent lookups for 601 tasks on two parents.
|
||||
|
||||
The summary now scans each category in primary-key pages of at most 200 rows.
|
||||
It retains at most ten display tasks and sorts them using the existing due-date
|
||||
order (earliest first, null last, then ID). Counts still include every visible
|
||||
matching row, rather than silently truncating at a display limit. Assignment and
|
||||
mention filters remain scoped to the signed-in user.
|
||||
|
||||
Each page caches parent visibility only for that page, using the existing caller
|
||||
permission and record-lock checks. Missing or revoked parents contribute neither
|
||||
rows nor counts; unexpected authorization/backend failures propagate. No decision
|
||||
is shared across requests. The starting maximum ID bounds each category scan so
|
||||
concurrent inserts cannot extend it indefinitely. Like the earlier summary, this
|
||||
is not a transactionally consistent snapshot across the three categories.
|
||||
|
||||
This bounds row retention, not total work independently of data size. For N
|
||||
matching rows, each category needs one high-watermark query plus approximately
|
||||
ceil(N/200) page queries, and one parent lookup per distinct parent per page.
|
||||
Many unique parents still require linear authorization work. Per-record heavy
|
||||
field size is also outside this row-count bound. Database and browser latency,
|
||||
N-node presence, saturation and production capacity remain part of #1291/#1302.
|
||||
|
||||
Regression evidence: the 601-task case passes a budget of at most eight parent
|
||||
queries, with no task query above 200 rows; correct top-ten ordering and revoked
|
||||
access on a subsequent request are checked. Another case crosses assignment and
|
||||
mention page boundaries and excludes hidden parents, other users and undelivered
|
||||
mentions. These tests use the framework's real memory backend instrumentation.
|
||||
|
||||
`MyWorkPostgresTest` repeats the 601-row case using PostgreSQL 17 and the generated
|
||||
production schema. Independent `pg_stat_statements` observations verify exactly
|
||||
five task queries and between one and eight parent queries. It also verifies the
|
||||
same top ten, visible count and next-request permission revocation against SQL.
|
||||
The fixture passes; these query counts are not sustained throughput or
|
||||
deployment-capacity measurements.
|
||||
|
||||
## Reproducible local workload
|
||||
|
||||
Run `mvn -Dtest=MyWorkPostgresTest -Dqqq.mywork.load=true test` with Java21 and
|
||||
Docker. The fixture owns a disposable PostgreSQL17 container, uses the generated
|
||||
schema and real authorization checks, and writes `target/mywork-load.csv`.
|
||||
It never connects to an application database. The optional workload runs after
|
||||
the normal query-budget regression so ordinary CI keeps the smaller test.
|
||||
|
||||
Each scenario has 2,000 tasks: one uses ten shared parents, the other 2,000 distinct
|
||||
parents. Every tenth parent is inaccessible. Two warmups precede ten measured
|
||||
sequential summaries. Every sample verifies 1,800 visible tasks and the exact top
|
||||
ten. SQL calls and server execution time are collected independently using
|
||||
pg_stat_statements after warmup. Latency includes service execution and database
|
||||
round trips, excluding fixture setup and assertions.
|
||||
|
||||
CSV percentiles use nearest rank. With only ten samples, p95 and p99 both equal
|
||||
the maximum observed sample; they are descriptive, not stable tail estimates.
|
||||
SQL figures are totals for all ten samples. This is a single-JVM local workload,
|
||||
not HTTP/browser latency, sustained concurrency, pool saturation, multi-node
|
||||
presence or a deployment-capacity claim. Do not derive production alert thresholds
|
||||
from these numbers. No wall-clock performance assertion is added to CI.
|
||||
|
||||
The fixture explicitly uses the framework C3P0 connection provider, matching the
|
||||
host's provider, with initial/minimum1, maximum3 and a two-second checkout timeout.
|
||||
It verifies the pool stays within that cap, has no checkout timeouts and returns
|
||||
all borrowed connections after each scenario. The original fixture used the
|
||||
unpooled default; the expanded workload failed with a Windows socket-allocation
|
||||
error after repeatedly opening connections. This was a fixture mismatch, not
|
||||
proof of a Kof22 outage. The same workload completed with pooling.
|
||||
|
||||
Observed local run (Java21, PostgreSQL17 in Docker on Windows, ten sequential
|
||||
samples after two warmups): shared-parent p50=91.192ms, max=112.105ms; distinct-parent
|
||||
p50=960.565ms, max=1100.260ms. SQL calls per summary were113 and2013 respectively.
|
||||
This confirms linear distinct-parent authorization work remains despite bounded
|
||||
row retention. The existing exact-count semantics still require examining every
|
||||
matching task. No runtime optimization or production-capacity claim follows from
|
||||
this fixture measurement alone.
|
||||
|
||||
## Compatibility with the later attachment changes
|
||||
|
||||
On October 26, an isolated source export combined attachment commit
|
||||
`dc65a826ce362416d55d91136fa39225f4a7cf94` with the four My Work files from
|
||||
`2ac067bea4af199486f90e2dc5212baf8910b8fb`. Both branches share base
|
||||
`15cb8cbf72aa9a5e033280cf5a38384d1295fc7d`; their changed files do not overlap.
|
||||
This was a source-overlay verification, not a branch merge or publication.
|
||||
|
||||
Java 21 `./mvnw.cmd verify -Dqqq.mywork.load=true` passed all 209 tests with zero
|
||||
failures, errors or skips, including the 29 attachment transaction tests.
|
||||
Packaging and JaCoCo gates passed. The optional PostgreSQL workload retained the
|
||||
same visible-count, top-ten, query-budget and pool assertions. Shared-parent
|
||||
p50/max were 76.301/96.044 ms; distinct-parent p50/max were 939.896/1035.402 ms.
|
||||
SQL counts remained 113 and 2013 per summary. These local measurements preserve
|
||||
the limitations above; they do not qualify an integrated published artifact or
|
||||
Kof22 deployment. The eventual integrated branch still needs its own CI gates.
|
||||
@@ -0,0 +1,179 @@
|
||||
# Presence recovery candidate — QQQ #1300
|
||||
|
||||
This is an unreleased implementation candidate in collaboration draft PR47.
|
||||
The existing store/service suites and CI pass; host adoption and enforced
|
||||
maintenance remain incomplete. It is not part of the published snapshots.
|
||||
|
||||
## Contract under qualification
|
||||
|
||||
Each deployment/application/tenant/table has a persistent recovery guard in the
|
||||
same Redis hash slot as its bounded room projections. Each Lua operation reads
|
||||
that guard atomically with its room work. The guard contains a generation, a
|
||||
readiness deadline and the latest issued expiry. A missing guard, or complete
|
||||
loss of table projections before that expiry, starts a fresh generation and a
|
||||
recovery interval of one lease lifetime. Existing live browsers can reserve and
|
||||
renew replacement leases during that interval. Snapshots report recovery rather
|
||||
than a successful empty room until the interval finishes.
|
||||
|
||||
Lease entries carry the guard generation. Recovery removes invalid-generation
|
||||
projections from touched rooms so they cannot consume all capacity and prevent
|
||||
re-registration. Existing authorization remains in PresenceService; the guard
|
||||
does not grant record, tenant or field access. The shared adapter maps recovery
|
||||
to a sanitized service-unavailable response. Record-version conflict checks are
|
||||
independent of this advisory state.
|
||||
|
||||
An untouched new namespace is conservatively unknown too. Its first snapshot
|
||||
therefore starts a recovery interval. Normal idle expiry retains the guard so
|
||||
later empty snapshots can be known-empty. The guard has no TTL; tenant-retirement
|
||||
operations and long-term guard storage must be included in operational capacity
|
||||
qualification.
|
||||
|
||||
## Controlled restore
|
||||
|
||||
The host configuration has a bounded `recoveryGeneration`, default `initial`,
|
||||
included in both the storage namespace and realtime audience. It belongs in
|
||||
deployment configuration outside the Redis backup. All nodes must use the same
|
||||
value. A restored guard can otherwise restore old authority; Redis process IDs
|
||||
cannot detect arbitrary same-process data restoration.
|
||||
|
||||
The required maintenance procedure is: stop admission and drain all old web
|
||||
nodes; restore Redis; choose a never-reused generation outside Redis; start only
|
||||
nodes with that generation; verify recovery and authenticated re-registration;
|
||||
then reopen traffic. Do not roll back the generation when rolling back an app.
|
||||
The host tooling that enforces and verifies this sequence is **not implemented**
|
||||
yet. The branch does not claim transparent arbitrary restore or split-brain
|
||||
recovery. Native Sentinel/Cluster routing remains the separate Redis issue #1301.
|
||||
|
||||
The storage prefix is v2 so old executables cannot treat generation-bound entries
|
||||
as old-format authority. This requires a coordinated deployment, not a mixed
|
||||
version rolling deployment. Existing published v1 snapshots are unchanged.
|
||||
|
||||
## Evidence and remaining work
|
||||
|
||||
- Before the patch, a two-client disposable Redis test failed because total loss
|
||||
returned an empty snapshot.
|
||||
- Seven focused tests now pass: full loss, loss of volatile projections while the
|
||||
guard survives, restored guard/lease bytes followed by a host generation
|
||||
change, bounded generation validation, and rejection of inconsistent lease
|
||||
lifetimes across nodes, guard loss across previously unvisited records, and
|
||||
shared cold readiness followed by normal idle expiry. The latter verifies
|
||||
that peer reads do not extend the deadline, the guard remains persistent,
|
||||
expired projections remain known-empty and expired leases cannot renew.
|
||||
These use independent clients in
|
||||
one JVM, not a deployed HA cluster.
|
||||
- Full wrapper verification passed on October 26: 184 tests, no failures,
|
||||
errors or skips; packaging and coverage gates passed. This includes the
|
||||
original seven store tests and two-node six-viewer/two-editor scenario.
|
||||
The admin dashboard reads every table, so the fixture initializes every
|
||||
dashboard table for both tenants through the real backend and waits for
|
||||
recovery without writing Redis guard state.
|
||||
- The service fixture socket read timeout is one second; its previous 200 ms
|
||||
limit caused a Redis EVALSHA read timeout. Production timeouts are unchanged.
|
||||
- The full run includes a real HTTP recovery test: real Redis
|
||||
FLUSHDB produces 503 during recovery, the former lease returns 409 with
|
||||
PRESENCE_LEASE_EXPIRED, hidden records and unauthorized viewers remain
|
||||
inaccessible, and a new lease rejoins through the production recovery window.
|
||||
This uses a loopback HTTP server with fixture authentication, not a browser
|
||||
or a deployed host. The focused test and the combined full suite both pass.
|
||||
- The store suite checks loss of either state or expiry data in both record
|
||||
and table projections. Server-issued lease renewal must fail unavailable
|
||||
without changing the surviving projection. It also covers complete loss
|
||||
of either projection pair and stale departure ordering.
|
||||
- Still required: deployed-host qualification and enforced host maintenance/reset. Local
|
||||
per-operation cost measurements at 1,000 and 10,000 leases are recorded in
|
||||
`acceptance/RECOVERY-COST.md`; sustained production capacity is unqualified.
|
||||
The implementation uses no INFO call or lifecycle polling cache.
|
||||
- This is an unreleased draft candidate. Publication and production deployment
|
||||
have not occurred; the remaining acceptance work above is still required.
|
||||
|
||||
## Independent-process acceptance
|
||||
|
||||
`docs/acceptance/presence_recovery_processes.py` passed on October 26 using
|
||||
two independent `SharedPresenceDevServer` JVMs and one disposable Redis container.
|
||||
Each user renews through the opposite node. After FLUSHDB, both nodes return 503
|
||||
for presence and 409 for the former leases; fresh leases restore both viewers on
|
||||
both nodes. The harness terminates only its owned processes and container.
|
||||
This is fixture authentication with independently seeded memory records, not a
|
||||
deployed HA application or a browser acceptance result.
|
||||
|
||||
Reproduce from the repository root after `./mvnw test-compile`:
|
||||
|
||||
```text
|
||||
./mvnw dependency:build-classpath -Dmdep.includeScope=test -Dmdep.outputFile=target/recovery-process-classpath.txt
|
||||
python docs/acceptance/presence_recovery_processes.py --java /path/to/java --repo /path/to/repository
|
||||
```
|
||||
|
||||
Requires Python 3, Java 21 and Docker. JVM output and argument files stay under
|
||||
`target/recovery-processes-*` for diagnosis. The harness uses the production
|
||||
45-second recovery window, with bounded startup and recovery deadlines.
|
||||
|
||||
## Browser recovery acceptance
|
||||
|
||||
The same harness accepts `--frontend-repo /path/to/qqq-frontend-next`. It copies
|
||||
that checkout's existing `out` export into the owned test output directory and
|
||||
serves it through the real middleware on both JVMs. Playwright is loaded from
|
||||
the frontend checkout; no API mocks are installed. Both browser users first
|
||||
see the other viewer, then Redis FLUSHDB triggers the unavailable message and
|
||||
removes stale people. Both viewer badges return without reloading either page.
|
||||
|
||||
The final fixed-candidate run passed all five profiles on October 26 against
|
||||
commit `4b0d6a32d6bd8ecba023857d3661d9f3f5980975`: Chromium, Firefox, WebKit,
|
||||
Pixel 7 emulation and iPad emulation. Source/compiled Lua hashes were checked
|
||||
before the run and after each profile. Traces and screenshots remain under
|
||||
each `target/recovery-processes-*` directory. The earlier Chromium screenshot
|
||||
in `target/recovery-processes-229f305e83` was also visually inspected.
|
||||
|
||||
Select profiles using `--browser-profile chromium|firefox|webkit|mobile|tablet`.
|
||||
These tests use the local export and do not establish provenance of a newly
|
||||
published frontend artifact, physical-device behavior or deployed Kof22
|
||||
acceptance. Fixture frontend serving is opt-in via `collab.dev.frontend`;
|
||||
its default remains false.
|
||||
|
||||
## Controlled restore acceptance
|
||||
|
||||
`acceptance/presence_restore_processes.py` exercises a disposable Redis RDB
|
||||
restore with two independent JVMs. It saves the populated dataset, verifies
|
||||
both old JVMs have terminated and their listeners have closed, clears the
|
||||
dataset, reloads the saved image and starts both nodes with a shared fresh
|
||||
generation supplied outside Redis. It checks recovery responses, rejects old
|
||||
leases on both nodes, and requires two fresh viewers to rejoin across nodes.
|
||||
The final run passed on October 26; logs are retained in
|
||||
`target/restore-processes-aec5288289`. Both leases are explicitly renewed before
|
||||
saving, and rejection must complete within 40 seconds of that renewal, before
|
||||
their normal 45-second expiry. Old leases are rejected again after fresh
|
||||
viewers have rejoined.
|
||||
|
||||
The fixture enables Redis DEBUG RELOAD only on its owned loopback container.
|
||||
Run after test compilation and classpath preparation:
|
||||
|
||||
```text
|
||||
python docs/acceptance/presence_restore_processes.py --java /path/to/java --repo /path/to/repository
|
||||
```
|
||||
|
||||
This is a protocol acceptance fixture, not host maintenance automation. Actual
|
||||
host admission closure, replacement prevention and generation configuration
|
||||
still require implementation and staging validation.
|
||||
|
||||
To qualify a packaged backend, add `--backend-jar /path/to/qbit-collaboration.jar`.
|
||||
This excludes production `target/classes` from the JVM classpath, rejects a
|
||||
duplicate collaboration dependency JAR, and records the artifact SHA-256 in
|
||||
`target/restore-processes-*/result.json`. The receipt includes the elapsed time
|
||||
for rejection of old leases (required to remain below their normal expiry),
|
||||
the restore/rejoin checks and fixture cleanup. Cleanup failure fails the run.
|
||||
The test repository still supplies compiled fixture classes and its dependency
|
||||
classpath; this is not a standalone production launcher.
|
||||
|
||||
## Packaged browser qualification
|
||||
|
||||
The process harness accepts `--backend-jar` and `--frontend-jar` for artifact
|
||||
verification. With a backend JAR, production `target/classes` is omitted from the
|
||||
runtime classpath; test classes provide only the disposable server fixture. The
|
||||
frontend JAR is served directly instead of copying a local `out` export.
|
||||
`--frontend-repo` remains required for Playwright tooling. Dependency classpaths
|
||||
must not contain a second collaboration or frontend artifact.
|
||||
|
||||
Each run writes `result.json` with the selected artifact SHA-256 hashes, browser
|
||||
profile, result and cleanup outcome. A successful test with failed fixture cleanup
|
||||
exits unsuccessfully. This records local artifact qualification, not publication
|
||||
or deployment provenance. Supply `--browser-profile` to qualify each required
|
||||
browser independently.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Known issues
|
||||
|
||||
- Deployment-specific object storage, database failover, backup/restore and retention policies need host qualification.
|
||||
- Docker must be running for the attachment transaction tests; enable both `mysqlJournalTests` and `postgresJournalTests` for the additional journal-provider cases. On Windows use `-Dexec.skip=true` for the inherited POSIX console-summary command.
|
||||
- Redis presence needs `qbit-redis` on the classpath; it is an optional dependency.
|
||||
- The matching Next UI and host configuration must be verified before rollout.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Collaboration — status
|
||||
|
||||
**Version 1.0**, built and published by the shared `ci-workflows` Maven pipeline to the KOF22 Gitea Maven registry (`com.kingsrook.qbits`); `mvn deploy` runs the tests once.
|
||||
|
||||
## What it contains
|
||||
|
||||
Attachment lifecycle with the retained upload journal, recovery and cleanup APIs; presence including Redis-backed presence; tags, custom fields, comments with mentions, sharing, favorites, follow, timeline and tasks; virtual-field and fixture support.
|
||||
|
||||
## Dependencies
|
||||
|
||||
QQQ Core `4.1.0-RC.22` (imported through its BOM; it provides the rollback callbacks the upload paths need). `qbit-redis` is optional and only needed for Redis presence. Build with Java 21 and `./mvnw verify`; Docker must be running for the attachment transaction tests (set `mysqlJournalTests` and `postgresJournalTests` for the extra journal-provider cases).
|
||||
|
||||
Host qualification of object storage, database failover, backup/restore and retention policy remains the deploying team's responsibility. See [known issues](QBIT-KNOWN-ISSUES.md).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,52 @@
|
||||
# Retained upload journal
|
||||
|
||||
The Collaboration-owned journal is wired into retained `AttachmentsService` upload and recovery. It persists candidate metadata before object creation, owns the attachment/checkpoint transaction, and exposes native read-only recovery navigation. It has no Record Files dependency and stores no second copy of file bytes.
|
||||
|
||||
The integrated owner snapshot passed all **214 tests**, including 10 real MySQL provider cases and 11 H2 journal regressions, and was installed under `0.1.0-record-files-SNAPSHOT`. The subsequent doublewrite/session-read-only delta compiles and awaits its provider rerun. This document does not qualify Aurora, other SQL vendors, external object storage, disks, backups, or a host deployment.
|
||||
|
||||
## Ownership and recovery
|
||||
|
||||
1. `prepare` independently commits the immutable server-built candidate before the first PUT. Matching retries reuse it; changed manifests conflict.
|
||||
2. `reserve` locks the row and grants a bounded lease with a monotonically increasing fence. `renew` extends only a still-live exact reservation at synchronous stage boundaries.
|
||||
3. `recordStorage(UNKNOWN)` precedes conditional object creation. `VERIFIED` means the storage owner proved the exact length and SHA-256.
|
||||
4. `register` passes its real transaction to the attachment registrar. The attachment insert and REGISTERED checkpoint commit together; callback failure rolls both back.
|
||||
5. The retained lifecycle registration runs after commit and is retried idempotently. The journal becomes COMPLETE only after it succeeds. Completed coordinated retries still invoke required lifecycle registration without adding a second attachment or activity event.
|
||||
6. Recovery reauthorizes the original actor and current source. It verifies the stored original without overwriting it and resumes registration. Missing or unreadable bytes remain uncertain; timeout never proves absence.
|
||||
|
||||
The owner rejects ambient/no-op transactions. SQL values are bound; mutable operations lock rows, check the token/fence/expiry, and require exactly one guarded update. Reads reconstruct manifest hashes. Private locator and lease columns are absent from QQQ metadata and forbidden in native filters/projections; native direct writes and unscoped totals are denied. Current actor/source authorization remains in the owner service and native journal security, not in the low-level JDBC port.
|
||||
|
||||
Disabled or deleted original actors still need an explicitly designed administrative reconciliation flow. No fabricated session or broad archive permission substitutes for that flow. Broad post-filtered listings can contain short pages; source-pinned native recovery navigation is the supported user entry point.
|
||||
|
||||
## Database boundaries
|
||||
|
||||
Both owner tables must use the same QQQ backend. Hosts apply DDL through their migrations; runtime code never creates tables or changes server-global settings.
|
||||
|
||||
| Backend | Runtime requirements | Schema |
|
||||
| --- | --- | --- |
|
||||
| File H2 | Native `h2`, `jdbc:h2:file:`, actual configured connection, writable real transaction, live `WRITE_DELAY=0` | `UploadJournalTable.h2Script()` |
|
||||
| MySQL 8.4 | Native `mysql`, actual MySQL 8.4 product/version, exact configured URL target and catalog, writable real transaction, both owner tables InnoDB, fixed owner table mappings, journal `utf8mb4_0900_bin` columns/table, single-column unique locator/intake indexes, synchronous commit settings | `UploadJournalTable.mysqlScript()` |
|
||||
| PostgreSQL 17 | Native `postgres` (vendor and backend type), actual PostgreSQL 17 product/version, exact configured `jdbc:postgresql://host/catalog` target and catalog, writable real transaction, not in recovery, `synchronous_commit` `on` or `remote_apply`, `fsync=on`, `full_page_writes=on` (see the Aurora rule below), session `TimeZone` UTC, `lock_timeout` from 1 to 5000 ms, READ COMMITTED, both owner tables permanent ordinary tables with fixed mappings, no non-deterministic collation, complete unique indexes on `locator_hash` and `intake_key` | `UploadJournalTable.postgresScript()` |
|
||||
| Amazon Aurora PostgreSQL 17 | Every PostgreSQL 17 requirement above, except that `full_page_writes=off` is accepted | `UploadJournalTable.postgresScript()` |
|
||||
| Aurora MySQL, MariaDB, other versions/providers | Refused; no inferred compatibility or durability claim | None |
|
||||
|
||||
Aurora rule: stock PostgreSQL must report `full_page_writes=on`, because recovery repairs a partly written page from the full page image in the write-ahead log. Amazon Aurora PostgreSQL reports `full_page_writes=off` and the parameter is not in its cluster parameter group, so a deployment cannot turn it on; its storage layer does not write torn pages, so the protection is not needed there. The qualification therefore accepts `off` only when the same settings query reports `to_regprocedure('aurora_version()') IS NOT NULL`, which is true on Aurora, false on stock PostgreSQL 17 and never raises. Nothing else is relaxed on Aurora: `synchronous_commit`, `fsync`, recovery state, isolation, time zone and lock timeout are checked exactly as on stock PostgreSQL. The detection is a name lookup, so a function named `aurora_version()` on the session's search path of a stock server is reported as Aurora; creating one is a schema change inside the host's trust boundary, like the settings themselves.
|
||||
|
||||
The MySQL adapter resolves explicit or native-generated URLs through QQQ `ConnectionManager`. It accepts a single ordinary `jdbc:mysql://host:port/catalog` target with a simple catalog identifier. Proxy/load-balancing URLs, alternate catalogs or table remapping fail closed. Error messages do not echo URLs or credentials. Driver options remain host-owned; TLS and credentials are not changed by the journal.
|
||||
|
||||
Each MySQL operation checks `innodb_flush_log_at_trx_commit=1`, and `sync_binlog=1` when binary logging is enabled; a read-only server or session is refused. It also requires `innodb_doublewrite` to be `ON` or `DETECT_AND_RECOVER`; [detect-only mode does not retain recoverable page copies](https://dev.mysql.com/doc/refman/8.4/en/innodb-doublewrite-buffer.html). These settings follow the [MySQL 8.4 InnoDB durability documentation](https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit) and [binary-log durability documentation](https://dev.mysql.com/doc/refman/8.4/en/replication-options-binary-log.html#sysvar_sync_binlog). Hardware that misreports flush completion can still lose data; replication/failover, backups, restore and disk guarantees remain deployment qualifications. Runtime settings are checked at transaction entry; an administrator changing server settings concurrently is outside the application trust boundary.
|
||||
|
||||
The MySQL journal uses millisecond timestamps with explicit UTC JDBC calendars, InnoDB, enforced CHECK constraints and binary NO PAD string comparisons. Its live session must report UTC (+00:00/UTC, or SYSTEM with a UTC system zone); the journal does not silently change a pooled session timezone. Hosts should configure the native JDBC driver timezone consistently for generic QQQ date fields as well. The host must configure each journal connection with `innodb_lock_wait_timeout` between 1 and 5 seconds, through its backend JDBC sessionVariables/pool setup (or a dedicated database default). Qualification reads and preserves that session setting; it never changes it or other pooled-session/global settings. Each statement also has a ten-second query timeout. Duplicate error 1062 is distinguished from unrelated integrity failures; deadlock/lock-timeout failures remain typed BUSY responses, never fabricated success.
|
||||
|
||||
## Reproducible tests
|
||||
|
||||
Use the repository Maven wrapper and the same revision/QQQ dependency overrides as the integrated build. The default suite remains Docker-independent; explicitly enable the provider fixture:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd '-Drevision=0.1.0-record-files-SNAPSHOT' '-DmysqlJournalTests=true' '-Dtest=MySqlUploadJournalTest,UploadJournalTest' test
|
||||
```
|
||||
|
||||
`MySqlUploadJournalTest` creates only its own Testcontainers MySQL 8.4.13 container, random synthetic credentials and private anonymous data volume, and destroys that container on completion. It does not use existing host services. The image is pinned to `mysql@sha256:a0225d1ea6631c8bc2385dc4c2faba17bbf1834ecf369e1659a9a382bd8d5dd7`. Credentials are not printed by the fixture.
|
||||
|
||||
The eleven provider cases cover acknowledged prepare surviving a real database process restart, attachment/checkpoint rollback and completion replay, simultaneous prepare/lease fencing, wrong backend/catalog/mapping/ambient/Aurora refusal, actual non-durable server settings and MyISAM refusal, binary identifier comparisons and native write denial, actual lock-timeout/BUSY behavior, column/index drift and immutable conflict, and lease instants across differing JVM default zones, preservation/refusal of host session lock-timeout settings, and refusal of detect-only doublewrite/read-only sessions without changing their settings. The attachment schema in this suite is deliberately a minimal transaction fixture; this is not an end-to-end MySQL object-storage or tenant/archive qualification. Record Files owns its separate provider integration tests.
|
||||
|
||||
First provider run compiled but failed its restart fixture because Docker reassigned the dynamic port and Testcontainers retained a cached mapping; two subsequent setup errors followed that stale endpoint. The fixture now refreshes the inspected binding after restart and uses bounded JDBC readiness checks. Docker wire logs are suppressed before container creation. Expanded snapshot verification: **20 tests passed (9 MySQL, 11 H2), zero failures/errors/skips**, `target/mysql-journal-focused-verification.log`, completed 2026-10-31 00:01:44. The tenth provider case and full 214-test owner quality gates subsequently passed; the corrected development-GAV artifact SHA-256 is `72D6E36E77B92BE3ECFE9BE7042D292C76B308AA05A9E43BD490B229BD1A75E2`. The eleventh provider regression and doublewrite/read-only delta await fresh verification.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Selectable collaboration fields: published feature snapshot
|
||||
|
||||
Tracks [QQQ #1196](https://github.com/QRun-IO/qqq/issues/1196).
|
||||
|
||||
This source branch requires the new core virtual-field value-provider API. Its
|
||||
default BOM pin is now the published
|
||||
`4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT`. Core publisher job 9716
|
||||
passed from commit `cc3981bb3c95e8f2dd7ef2a02e2233db7d0e2a79`; an independent
|
||||
consumer with an initially empty Maven repository downloaded its BOM and core
|
||||
and compiled the provider interface on October 26, 2026. Both artifacts resolve
|
||||
to timestamp `20261026.130631-1` from Sonatype's snapshot repository.
|
||||
|
||||
Published artifact SHA-256 receipts:
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| --- | --- |
|
||||
| BOM POM | `d68cf837d7e07513c4e70977829f99e6236cb3e89f8af85fc75239d5b52564a5` |
|
||||
| Core POM | `d89d491aaa9312051ec462e1cf165b948ee3e88c9aec2721ff25a00e956c431b` |
|
||||
| Core JAR | `ae6dbeba8d0e4bc55f2714303ee3482dbf6a0cd34c03663c6009d8fe2c1da54d` |
|
||||
|
||||
The collaboration follow-up is published as
|
||||
`0.1.0-field-values-1196-ecff347-SNAPSHOT`, timestamp
|
||||
`20261026.133203-1`. Publisher job 168 passed from commit
|
||||
`ecff347efe2bb84949b8452a3383e3fdeb4bcede`, using signed tag
|
||||
`publish-values1196-20261026-ecff347`.
|
||||
|
||||
| Collaboration artifact | SHA-256 |
|
||||
| --- | --- |
|
||||
| POM | `cc12f59cbdfd4bc53ba545a9f2b6d7c14a316034b107ef5541ddc48df45fe6af` |
|
||||
| JAR | `284fa370504dd82cee8c87d2b8f5c0cecba8c4b15b32a4f9d3d62a0a062e549d` |
|
||||
|
||||
On October 26, an independent Java 21 consumer downloaded this pair into an
|
||||
initially empty Maven repository and compiled both `TagValueProvider` and
|
||||
`CustomFieldValueProvider` as `VirtualFieldValueProviderInterface` instances.
|
||||
Maven recorded Sonatype `central-portal-snapshots` as the artifact source. The
|
||||
downloaded collaboration POM pins the core version above; the independently
|
||||
downloaded core JAR matches the receipt above. This checks published dependency
|
||||
resolution and API compatibility; runtime behavior is covered by the 198-test
|
||||
verification below, not by this compilation probe.
|
||||
|
||||
This snapshot does not include the separate attachment, My Work or presence
|
||||
recovery follow-up PRs. It has not been deployed to Kof22.
|
||||
|
||||
Earlier local verification used core artifacts installed as
|
||||
`4.1.0-values1196-SNAPSHOT`, with
|
||||
`./mvnw.cmd -Dqqq.snapshot.version=4.1.0-values1196-SNAPSHOT verify`.
|
||||
That version exists only in the local Maven repository; it is not an adoption pin.
|
||||
|
||||
## Behavior
|
||||
|
||||
- `collaborationTags` returns sorted, distinct approved labels from global or
|
||||
matching-table vocabulary. Merged tags and deleted assignments are excluded.
|
||||
The picker and filters retain their tag IDs; exports contain one Tags column.
|
||||
- EAV custom fields return typed existing values and ordinary possible-value
|
||||
display labels. Missing values are null internally and omitted from HTTP value
|
||||
maps. This change does not enable EAV writes.
|
||||
- Providers process batches and use the originating transaction. Parent record
|
||||
authorization and field visibility remain enforced by core. Existing host
|
||||
post-query customizers remain installed.
|
||||
- SQL DISTINCT on provider values is explicitly unsupported. Backend primary-key
|
||||
identity is required; joined heavy primary keys must be fetched explicitly.
|
||||
|
||||
## Release sequence
|
||||
|
||||
1. Publish and verify the coordinated core artifact containing the provider API (done).
|
||||
2. Replace this branch's core BOM pin with that published version (done).
|
||||
3. Run full collaboration verification against the published prerequisite
|
||||
(done October 26: 198 tests, no failures/errors/skips; packaging and coverage
|
||||
passed, including HTTP, PostgreSQL transaction and Redis tests).
|
||||
4. Publish the collaboration snapshot and verify an independent consumer using
|
||||
published artifacts (done; exact pair recorded in CANDIDATE-RELEASE.md).
|
||||
5. Update Kof22 Website-Backend adoption work and qualify it in the selected
|
||||
isolated staging environment before requesting production deployment.
|
||||
|
||||
The published pair contains this implementation. Kof22 adoption, a compatible
|
||||
packaged frontend and staging qualification remain separate release steps.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Recovery cost diagnostic — October 26, 2026
|
||||
|
||||
One Java 21 process, one Redis connection, disposable loopback Redis 7.4-alpine,
|
||||
1,000 issued leases, ten viewers per record, no TLS or HTTP. The probe keeps
|
||||
all leases alive through the real 45-second recovery window and verifies that
|
||||
the final table snapshot contains all 1,000 viewers. Each phase samples 200
|
||||
renewals with increasing sequence numbers. This is not a capacity or SLA claim.
|
||||
|
||||
| Renewal latency (ms) | During recovery | Ready |
|
||||
|---|---:|---:|
|
||||
| p50 | 3.6779 | 0.5134 |
|
||||
| p95 | 4.1956 | 0.7226 |
|
||||
| p99 | 4.5447 | 0.9122 |
|
||||
| Maximum | 6.8271 | 1.4886 |
|
||||
|
||||
Population took 4,640.799 ms. The run passed all correctness checks. This first
|
||||
implementation scanned touched projections on every request during recovery.
|
||||
|
||||
After changing table cleanup to occur atomically only when creating its recovery
|
||||
generation (individual record cleanup remains lazy), the same diagnostic passed:
|
||||
|
||||
| Renewal latency (ms) | Optimized recovery | Ready |
|
||||
|---|---:|---:|
|
||||
| p50 | 0.6375 | 0.5029 |
|
||||
| p95 | 0.8150 | 0.5762 |
|
||||
| p99 | 0.9406 | 0.7500 |
|
||||
| Maximum | 4.2280 | 1.2251 |
|
||||
|
||||
Optimized population took 1,634.486 ms. All 1,000 leases survived. The focused
|
||||
recovery/store suite also passed 13 tests, including guard loss with previously
|
||||
unvisited record rooms. Full optimized verification passed 184 tests with no failures/errors/skips and coverage gates passed. These are
|
||||
individual local runs and need further qualification before claiming supported
|
||||
production capacity. They do not isolate every source
|
||||
of latency, compare against an earlier release, or measure sustained concurrent
|
||||
load, saturation, TLS, HTTP or browser propagation.
|
||||
|
||||
At 10,000 entries the optimized diagnostic also passed, retaining all leases.
|
||||
Population took 10,687.2105 ms; the same 200-sample phases measured:
|
||||
|
||||
| Renewal latency (ms) | Optimized recovery, 10,000 entries | Ready |
|
||||
|---|---:|---:|
|
||||
| p50 | 0.4978 | 0.5341 |
|
||||
| p95 | 0.5914 | 0.7108 |
|
||||
| p99 | 0.7255 | 1.2964 |
|
||||
| Maximum | 0.7544 | 1.8799 |
|
||||
|
||||
Do not infer that larger tables are faster from these separate local runs.
|
||||
JIT warmup, scheduling and concurrent machine activity were not controlled.
|
||||
The useful result is that the optimization avoids a repeated whole-table scan
|
||||
during recovery and both populated-table runs preserved lease correctness.
|
||||
|
||||
The explicit `RedisPresenceRecoveryCostProbe` test-source main class is not
|
||||
automatically run by the unit suite and imposes no machine-dependent CI gate.
|
||||
After test compilation, run it on the test classpath with argument `1000`.
|
||||
It fails if the real recovery window ends before the cold measurement, rather
|
||||
than silently reporting ready-state latency as recovery latency.
|
||||
@@ -0,0 +1,52 @@
|
||||
// Real browser recovery against the disposable two-JVM harness. No API mocks.
|
||||
import { createRequire } from 'node:module'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
|
||||
const [frontend, first, second, container, output, profile = 'chromium'] = process.argv.slice(2)
|
||||
const require = createRequire(path.join(frontend, 'package.json'))
|
||||
const { chromium, firefox, webkit, devices, expect } = require('@playwright/test')
|
||||
const profiles = {
|
||||
chromium: [chromium, devices['Desktop Chrome']],
|
||||
firefox: [firefox, devices['Desktop Firefox']],
|
||||
webkit: [webkit, devices['Desktop Safari']],
|
||||
mobile: [chromium, devices['Pixel 7']],
|
||||
tablet: [webkit, devices['iPad (gen 7)']],
|
||||
}
|
||||
if (!profiles[profile]) throw new Error(`Unknown browser profile: ${profile}`)
|
||||
const [engine, device] = profiles[profile]
|
||||
const browser = await engine.launch({ headless: true })
|
||||
const contexts = []
|
||||
try {
|
||||
const pages = []
|
||||
for (const [port, user] of [[first, 'alice'], [second, 'bob']]) {
|
||||
const context = await browser.newContext(device)
|
||||
contexts.push(context)
|
||||
await context.tracing.start({ screenshots: true, snapshots: true })
|
||||
const page = await context.newPage()
|
||||
await page.goto(`http://127.0.0.1:${port}/?createMockSession=true&user=${user}`)
|
||||
// The root export redirects after hydration; finish it before record navigation.
|
||||
await page.waitForURL(url => /^\/app\/?$/.test(url.pathname), { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(`http://127.0.0.1:${port}/app/person/1`)
|
||||
pages.push(page)
|
||||
}
|
||||
const unavailable = page => page.getByText('Presence temporarily unavailable', { exact: true })
|
||||
await expect(pages[0].locator('[data-qqq-id="presence-bob"]')).toBeVisible({ timeout: 90000 })
|
||||
await expect(pages[1].locator('[data-qqq-id="presence-alice"]')).toBeVisible({ timeout: 90000 })
|
||||
execFileSync('docker', ['exec', container, 'redis-cli', 'FLUSHDB'])
|
||||
for (const page of pages) await expect(unavailable(page)).toBeVisible({ timeout: 35000 })
|
||||
await expect(pages[0].locator('[data-qqq-id="presence-bob"]')).toHaveCount(0)
|
||||
await expect(pages[1].locator('[data-qqq-id="presence-alice"]')).toHaveCount(0)
|
||||
await expect(pages[0].locator('[data-qqq-id="presence-bob"]')).toBeVisible({ timeout: 90000 })
|
||||
await expect(pages[1].locator('[data-qqq-id="presence-alice"]')).toBeVisible({ timeout: 90000 })
|
||||
for (const [index, page] of pages.entries()) {
|
||||
await expect(unavailable(page)).toHaveCount(0)
|
||||
await page.screenshot({ path: path.join(output, `recovered-${index}.png`) })
|
||||
}
|
||||
console.log(`PASS: ${profile} shows recovery unavailable, clears stale people, and rejoins both JVMs without reload.`)
|
||||
} finally {
|
||||
for (const [index, context] of contexts.entries()) {
|
||||
await context.tracing.stop({ path: path.join(output, `browser-${index}.zip`) })
|
||||
}
|
||||
await browser.close()
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Exercise Redis loss through two independent SharedPresenceDevServer JVMs.
|
||||
|
||||
Run after test-compile and dependency:build-classpath with test scope and output
|
||||
target/recovery-process-classpath.txt. Uses only disposable loopback fixtures.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
|
||||
def free_port():
|
||||
"""Reserve a candidate loopback port; startup detects any bind race."""
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
def request(port, user, method, path, body=None):
|
||||
"""Use the fixture's mock session through the actual HTTP boundary."""
|
||||
separator = "&" if "?" in path else "?"
|
||||
url = f"http://127.0.0.1:{port}/qqq/v1/qbit/collaboration{path}{separator}user={user}"
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||
"Cookie": f"sessionUUID={user}-recovery-processes",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
try:
|
||||
response = urllib.request.urlopen(req, timeout=10)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
return response.status, json.load(response)
|
||||
|
||||
|
||||
def require(status, result):
|
||||
"""Fail with fixture-only response evidence when transport differs."""
|
||||
assert result[0] == status, result
|
||||
return result[1]
|
||||
|
||||
|
||||
def settle(ports, leases):
|
||||
"""Renew on the opposite node until both nodes report both people."""
|
||||
deadline = time.monotonic() + 60
|
||||
sequence = 0
|
||||
while time.monotonic() < deadline:
|
||||
sequence += 1
|
||||
for index, (user, lease) in enumerate(leases.items()):
|
||||
result = request(ports[1 - index], user, "POST", "/presence/leases/renew", {
|
||||
"connectionId": lease, "table": "person", "pk": "1",
|
||||
"mode": "VIEWING", "sequence": sequence,
|
||||
})
|
||||
assert result[0] in (200, 503), result
|
||||
if result[0] == 503:
|
||||
assert "recovering" in result[1]["message"], result
|
||||
snapshots = [request(port, "alice", "GET", "/presence?table=person&pk=1") for port in ports]
|
||||
if all(status == 200 for status, _ in snapshots):
|
||||
assert all(len(body["viewers"]) == 2 for _, body in snapshots), snapshots
|
||||
return
|
||||
assert all(status == 503 for status, _ in snapshots), snapshots
|
||||
time.sleep(1)
|
||||
raise AssertionError("Two-process recovery exceeded 60 seconds")
|
||||
|
||||
|
||||
def main():
|
||||
"""Own and clean up the exact child JVMs and disposable Redis container."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java", required=True)
|
||||
parser.add_argument("--repo", type=Path, required=True)
|
||||
parser.add_argument("--frontend-repo", type=Path)
|
||||
parser.add_argument("--frontend-jar", type=Path, help="Serve this packaged frontend instead of a local export")
|
||||
parser.add_argument("--backend-jar", type=Path, help="Use this backend artifact instead of target/classes")
|
||||
parser.add_argument("--browser-profile", choices=("chromium", "firefox", "webkit", "mobile", "tablet"), default="chromium")
|
||||
args = parser.parse_args()
|
||||
repo = args.repo.resolve()
|
||||
output = repo / "target" / ("recovery-processes-" + uuid.uuid4().hex[:10])
|
||||
output.mkdir()
|
||||
if args.frontend_jar and not args.frontend_repo:
|
||||
parser.error("frontend-jar requires frontend-repo for browser tooling")
|
||||
dependencies = (repo / "target/recovery-process-classpath.txt").read_text().strip().split(os.pathsep)
|
||||
assert not any(Path(item).name.startswith(("qbit-collaboration-", "qqq-frontend-next-")) for item in dependencies)
|
||||
backend = args.backend_jar.resolve(strict=True) if args.backend_jar else repo / "target/classes"
|
||||
classpath = os.pathsep.join([str(repo / "target/test-classes"), str(backend), *dependencies])
|
||||
evidence = {"status": "FAIL", "browser_profile": args.browser_profile, "cleanup": False}
|
||||
if args.backend_jar:
|
||||
evidence["backend_jar_sha256"] = hashlib.sha256(backend.read_bytes()).hexdigest()
|
||||
if args.frontend_jar:
|
||||
frontend = args.frontend_jar.resolve(strict=True)
|
||||
evidence["frontend_jar_sha256"] = hashlib.sha256(frontend.read_bytes()).hexdigest()
|
||||
classpath = str(frontend) + os.pathsep + classpath
|
||||
elif args.frontend_repo:
|
||||
shutil.copytree(args.frontend_repo.resolve() / "out", output / "next-dashboard")
|
||||
classpath = str(output) + os.pathsep + classpath
|
||||
container = "qqq-recovery-" + uuid.uuid4().hex[:12]
|
||||
processes, logs = [], []
|
||||
try:
|
||||
subprocess.run(["docker", "run", "-d", "--name", container, "-p", "127.0.0.1::6379", "redis:7.4-alpine"], check=True, capture_output=True)
|
||||
binding = subprocess.check_output(["docker", "port", container, "6379"], text=True).strip()
|
||||
redis_port = int(binding.rsplit(":", 1)[1])
|
||||
ports = [free_port(), free_port()]
|
||||
for port in ports:
|
||||
argfile = output / f"node-{port}.args"
|
||||
argfile.write_text('-Dcollab.dev.frontend=' + str(bool(args.frontend_repo)).lower() + '\n-cp\n"' + classpath.replace("\\", "/") + '"\n'
|
||||
'com.kingsrook.qbits.collaboration.devserver.SharedPresenceDevServer\n'
|
||||
f'{port}\n{redis_port}\n', encoding="utf-8")
|
||||
log = (output / f"node-{port}.log").open("w")
|
||||
logs.append(log)
|
||||
processes.append(subprocess.Popen([args.java, "@" + str(argfile)], cwd=repo,
|
||||
stdout=log, stderr=subprocess.STDOUT,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)))
|
||||
deadline = time.monotonic() + 60
|
||||
for port, process in zip(ports, processes):
|
||||
while f"SHARED_PRESENCE_READY {port}" not in (output / f"node-{port}.log").read_text(errors="replace"):
|
||||
assert process.poll() is None, f"Node exited; see {output}"
|
||||
assert time.monotonic() < deadline, f"Startup timed out; see {output}"
|
||||
time.sleep(0.2)
|
||||
if args.frontend_repo:
|
||||
subprocess.run(["node", str(repo / "docs/acceptance/presence_recovery_browser.mjs"),
|
||||
str(args.frontend_repo.resolve()), str(ports[0]), str(ports[1]), container, str(output), args.browser_profile], check=True)
|
||||
evidence["status"] = "PASS"
|
||||
return
|
||||
def join():
|
||||
"""Acquire each person's new lease on a different node."""
|
||||
return {user: require(200, request(port, user, "POST", "/presence/leases", {
|
||||
"table": "person", "pk": "1"}))["connectionId"] for port, user in zip(ports, ("alice", "bob"))}
|
||||
original = join()
|
||||
settle(ports, original)
|
||||
subprocess.run(["docker", "exec", container, "redis-cli", "FLUSHDB"], check=True, capture_output=True)
|
||||
for port in ports:
|
||||
require(503, request(port, "alice", "GET", "/presence?table=person&pk=1"))
|
||||
for user, lease in original.items():
|
||||
error = require(409, request(port, user, "POST", "/presence/leases/renew", {
|
||||
"connectionId": lease, "table": "person", "pk": "1", "mode": "VIEWING", "sequence": 1000}))
|
||||
assert error["code"] == "PRESENCE_LEASE_EXPIRED", error
|
||||
replacement = join()
|
||||
assert all(replacement[user] != original[user] for user in original)
|
||||
settle(ports, replacement)
|
||||
print(f"PASS: two independent JVMs recovered two viewers after Redis loss. Logs: {output}")
|
||||
evidence["status"] = "PASS"
|
||||
except Exception as error:
|
||||
evidence["failure"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
for process in processes:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
for log in logs:
|
||||
log.close()
|
||||
cleanup = subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False)
|
||||
evidence["cleanup"] = cleanup.returncode == 0 and all(process.poll() is not None for process in processes)
|
||||
(output / "result.json").write_text(json.dumps(evidence, indent=2), encoding="utf-8")
|
||||
print(output, flush=True)
|
||||
if evidence["status"] == "PASS" and not evidence["cleanup"]:
|
||||
raise RuntimeError("Recovery passed but owned fixture cleanup failed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Restore a disposable Redis image while both fixture JVMs are stopped.
|
||||
|
||||
This verifies the controlled-restore protocol, not production orchestration.
|
||||
Requires the same compiled test classpath as presence_recovery_processes.py.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from presence_recovery_processes import free_port, request, require, settle
|
||||
|
||||
|
||||
def main():
|
||||
"""Own a Redis fixture and two generations of independently running nodes."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java", required=True)
|
||||
parser.add_argument("--repo", type=Path, required=True)
|
||||
parser.add_argument("--backend-jar", type=Path, help="Verify this artifact without production target/classes")
|
||||
args = parser.parse_args()
|
||||
repo = args.repo.resolve()
|
||||
output = repo / "target" / ("restore-processes-" + uuid.uuid4().hex[:10])
|
||||
output.mkdir()
|
||||
dependencies = (repo / "target/recovery-process-classpath.txt").read_text().strip().split(os.pathsep)
|
||||
assert all(Path(item).is_file() and item.endswith('.jar') for item in dependencies)
|
||||
assert not any(Path(item).name.startswith("qbit-collaboration-") for item in dependencies)
|
||||
backend = args.backend_jar.resolve(strict=True) if args.backend_jar else repo / "target/classes"
|
||||
classpath = os.pathsep.join([str(repo / "target/test-classes"), str(backend), *dependencies])
|
||||
evidence = {"status": "FAIL", "cleanup": False, "checks": []}
|
||||
if args.backend_jar:
|
||||
evidence["backend_jar_sha256"] = hashlib.sha256(backend.read_bytes()).hexdigest()
|
||||
container = "qqq-restore-" + uuid.uuid4().hex[:12]
|
||||
container_created = False
|
||||
processes, logs = [], []
|
||||
ports = [free_port(), free_port()]
|
||||
|
||||
def redis(*command):
|
||||
"""Run commands only against this owned, disposable Redis container."""
|
||||
return subprocess.check_output(["docker", "exec", container, "redis-cli", *command], text=True).strip()
|
||||
|
||||
def stop_nodes():
|
||||
"""Require process termination and closed old listeners before restore."""
|
||||
for process in processes:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
processes.clear()
|
||||
for port in ports:
|
||||
with socket.socket() as connection:
|
||||
connection.settimeout(1)
|
||||
assert connection.connect_ex(("127.0.0.1", port)) != 0, "Old listener remains open"
|
||||
|
||||
def start_nodes(generation, redis_port):
|
||||
"""Supply the shared generation outside the Redis image, on both JVMs."""
|
||||
for port in ports:
|
||||
argfile = output / f"{generation}-{port}.args"
|
||||
argfile.write_text(f'-Dcollab.dev.recoveryGeneration={generation}\n-cp\n"'
|
||||
+ classpath.replace("\\", "/") + '"\n'
|
||||
'com.kingsrook.qbits.collaboration.devserver.SharedPresenceDevServer\n'
|
||||
f'{port}\n{redis_port}\n', encoding="utf-8")
|
||||
logfile = output / f"{generation}-{port}.log"
|
||||
log = logfile.open("w")
|
||||
logs.append(log)
|
||||
processes.append(subprocess.Popen([args.java, "@" + str(argfile)], cwd=repo,
|
||||
stdout=log, stderr=subprocess.STDOUT,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)))
|
||||
deadline = time.monotonic() + 60
|
||||
for port, process in zip(ports, processes):
|
||||
logfile = output / f"{generation}-{port}.log"
|
||||
while f"SHARED_PRESENCE_READY {port}" not in logfile.read_text(errors="replace"):
|
||||
assert process.poll() is None, f"Node exited; see {output}"
|
||||
assert time.monotonic() < deadline, f"Startup timed out; see {output}"
|
||||
time.sleep(0.2)
|
||||
|
||||
def join():
|
||||
"""Issue each person's lease on a separate node."""
|
||||
return {user: require(200, request(port, user, "POST", "/presence/leases", {
|
||||
"table": "person", "pk": "1"}))["connectionId"] for port, user in zip(ports, ("alice", "bob"))}
|
||||
|
||||
def reject_old(leases, sequence):
|
||||
"""Restored lease authority must fail on both new-generation nodes."""
|
||||
for port in ports:
|
||||
for user, lease in leases.items():
|
||||
error = require(409, request(port, user, "POST", "/presence/leases/renew", {
|
||||
"connectionId": lease, "table": "person", "pk": "1",
|
||||
"mode": "VIEWING", "sequence": sequence}))
|
||||
assert error["code"] == "PRESENCE_LEASE_EXPIRED", error
|
||||
|
||||
try:
|
||||
# DEBUG RELOAD is enabled only on this disposable loopback fixture.
|
||||
subprocess.run(["docker", "run", "-d", "--name", container, "-p", "127.0.0.1::6379",
|
||||
"redis:7.4-alpine", "redis-server", "--enable-debug-command", "yes", "--save", ""],
|
||||
check=True, capture_output=True)
|
||||
container_created = True
|
||||
binding = subprocess.check_output(["docker", "port", container, "6379"], text=True).strip()
|
||||
redis_port = int(binding.rsplit(":", 1)[1])
|
||||
start_nodes("before-restore", redis_port)
|
||||
original = join()
|
||||
settle(ports, original)
|
||||
last_renewal = time.monotonic()
|
||||
for port, (user, lease) in zip(ports, original.items()):
|
||||
require(200, request(port, user, "POST", "/presence/leases/renew", {
|
||||
"connectionId": lease, "table": "person", "pk": "1",
|
||||
"mode": "VIEWING", "sequence": 100}))
|
||||
assert redis("SAVE") == "OK"
|
||||
before = int(redis("DBSIZE"))
|
||||
assert before > 0
|
||||
stop_nodes()
|
||||
evidence["checks"].append("old JVMs stopped and listeners closed before restore")
|
||||
assert redis("FLUSHDB") == "OK"
|
||||
assert redis("DBSIZE") == "0"
|
||||
assert redis("DEBUG", "RELOAD", "NOSAVE") == "OK"
|
||||
assert int(redis("DBSIZE")) > 0, "Saved Redis image was not restored"
|
||||
evidence["checks"].append("nonempty saved Redis image restored")
|
||||
start_nodes("restored-" + uuid.uuid4().hex, redis_port)
|
||||
reject_old(original, 1000)
|
||||
# Avoid a false pass caused only by the old 45-second lease expiring.
|
||||
evidence["old_lease_rejection_seconds"] = time.monotonic() - last_renewal
|
||||
assert evidence["old_lease_rejection_seconds"] < 40, "Restore exceeded the live-lease proof window"
|
||||
evidence["checks"].append("both nodes reject old leases before normal expiry")
|
||||
for port in ports:
|
||||
result = require(503, request(port, "alice", "GET", "/presence?table=person&pk=1"))
|
||||
assert "recovering" in result["message"], result
|
||||
replacement = join()
|
||||
assert all(replacement[user] != original[user] for user in original)
|
||||
settle(ports, replacement)
|
||||
reject_old(original, 1001)
|
||||
evidence["checks"].append("both nodes recover with fresh viewers and still reject old leases")
|
||||
if args.backend_jar:
|
||||
assert hashlib.sha256(backend.read_bytes()).hexdigest() == evidence["backend_jar_sha256"]
|
||||
evidence["status"] = "PASS"
|
||||
print(f"PASS: restored Redis image cannot authorize old leases on either new-generation JVM. Logs: {output}")
|
||||
except Exception as error:
|
||||
evidence["failure"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
try:
|
||||
stop_nodes()
|
||||
finally:
|
||||
for log in logs:
|
||||
log.close()
|
||||
if container_created:
|
||||
subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=True)
|
||||
evidence["cleanup"] = True
|
||||
except Exception as error:
|
||||
evidence["status"] = "FAIL"
|
||||
evidence["cleanup_failure"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
(output / "result.json").write_text(json.dumps(evidence, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,264 @@
|
||||
# Independent-node presence workload
|
||||
|
||||
This fixture targets QQQ #1302/#1291. It runs 2–8 independent JVM HTTP servers
|
||||
against an owned loopback Redis container. Every node serves the same configured
|
||||
mock identities across 2–8 tenant scopes. The scope comes from the authenticated
|
||||
fixture identity, never a supplied tenant parameter. Records are independently
|
||||
seeded in memory; this does not qualify shared database or production IAM behavior.
|
||||
|
||||
Build with Java 21:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd test-compile dependency:build-classpath '-Dmdep.includeScope=test' '-Dmdep.outputFile=target/capacity-classpath.txt'
|
||||
python docs/performance/presence_nodes.py --java /path/to/java --nodes 3 --tenants 2 --users 6 --tabs 4 --rounds 100
|
||||
```
|
||||
|
||||
Four tabs per user means two duplicate tabs on each of two records. Every round
|
||||
renews each issued lease on a rotating node, with sequential per-tab sequences.
|
||||
All nodes must then return exactly the expected tenant's distinct people and
|
||||
exactly two editors on both records. Any mismatch or request failure fails the
|
||||
run. Requests use bounded worker concurrency and ten-second timeouts. Each node
|
||||
has eight pooled Redis connections, a 500 ms pool wait and a 384 MiB heap ceiling.
|
||||
|
||||
Results and per-node logs are retained under `target/capacity-nodes-<id>`.
|
||||
The result records parameters, node PIDs, elapsed time, sample counts and
|
||||
nearest-rank HTTP p50/p95/p99/max. Renewal throughput includes snapshot checks
|
||||
and is not maximum server throughput. All owned JVMs and the Redis container are
|
||||
removed in `finally`; the receipt reports cleanup separately from test success.
|
||||
|
||||
Each fixture JVM emits resource samples every 100 ms using Java management APIs
|
||||
and the Redis QBit's existing pool metrics. The receipt summarizes sampled heap
|
||||
used/committed bytes, active/idle/waiting pool counts and the configured maximum.
|
||||
CPU core equivalents are process CPU time divided by observed wall time; they
|
||||
can exceed one for a multithreaded JVM. Observation begins after server startup
|
||||
and includes lease setup and workload execution. Unsupported CPU counters are
|
||||
reported as null. Linux fixtures also read process `VmRSS` from `/proc/self/status`
|
||||
every 100 ms and record `residentBytes_sampled_max`; unsupported platforms report
|
||||
null. Linux-container runs require resident-memory samples. Heap is not total
|
||||
resident memory, and process RSS is not the container's complete cgroup footprint.
|
||||
Sampled peaks may miss
|
||||
short bursts and must not be represented as exact maxima. Raw samples remain in
|
||||
node logs; missing node samples or a sampled pool-bound violation fail the run.
|
||||
|
||||
The final Redis INFO capture records `used_memory`, lifetime `used_memory_peak`,
|
||||
and current `used_memory_rss`, plus process user/system CPU seconds since Redis
|
||||
startup. Redis RSS is an end-of-run observation, not a sampled peak. These fresh,
|
||||
owned Redis instances include fixture setup and diagnostics in their lifetime;
|
||||
do not interpret those CPU values as workload-only measurements or add independent
|
||||
process peaks and call the sum a measured simultaneous maximum. Missing Redis
|
||||
memory/CPU diagnostics fail acceptance while cleanup still runs.
|
||||
|
||||
The [100-user resource run](results/linux-100-users-resources.json) used three
|
||||
Linux JVMs, two tenants with 50 people each, four leases per person, 16 request
|
||||
workers and 200 rounds. All 80,000 renewals and 2,400 exact-membership snapshots
|
||||
passed in 102.568 workload seconds. Renewal p50/p95/p99/max were
|
||||
11.326/21.765/25.881/1,007.404 ms; snapshot p99 was 19.424 ms. The one-second
|
||||
renewal maximum is retained, not discarded. Cleanup passed.
|
||||
|
||||
Sampled JVM RSS peaks were 662,786,048 / 662,188,032 / 703,479,808 bytes
|
||||
(approximately 632–671 MiB), while used-heap peaks were about 171–177 MiB.
|
||||
CPU observations were 1.73–1.83 core equivalents per JVM; sampled pool-active
|
||||
peaks were 7/7/6 of eight with no sampled waiters. Redis peak allocated memory
|
||||
was 2,579,896 bytes; final RSS was 8,777,728 bytes. Redis process CPU since
|
||||
startup was 23.738 system plus 35.526 user seconds. These measurements include
|
||||
fixture overhead, omit browser load, and do not establish deployment limits.
|
||||
|
||||
Initial smoke run: three JVMs, two tenants, six users each, four tabs per user,
|
||||
three rounds: 144 renewals and 36 snapshots passed with tenant isolation,
|
||||
duplicate-tab deduplication and two editors retained. Cleanup passed. This short
|
||||
run validates the harness, not sustained capacity or operational thresholds.
|
||||
|
||||
A second run with 12 users per tenant and 100 rounds passed 9,600 renewals and
|
||||
1,200 snapshots in 23.110 seconds. Renewal p50/p95/p99 were 7.246/16.454/21.853 ms;
|
||||
snapshot p50/p95/p99 were 12.908/15.776/18.239 ms. Every round checked exact
|
||||
tenant membership, deduplication and two editors on both records at every node.
|
||||
Cleanup passed. These are measurements from one local Windows/Docker run;
|
||||
they do not establish deployment limits or browser propagation latency.
|
||||
|
||||
## Sustained-run limitation under investigation
|
||||
|
||||
The 1,000-round workload has intermittently failed with HTTP 503 on lease
|
||||
renewal. Four runs failed; one completed 96,000 renewals and 12,000 snapshots.
|
||||
The passing run does not resolve the failures. Temporary local diagnostics on
|
||||
the third failure identified `JedisConnectionException` caused by
|
||||
`SocketTimeoutException` while reading the `EVALSHA` response in
|
||||
`RedisPresenceStore.mutate`. The configured socket timeout remained two seconds.
|
||||
The temporary production-file diagnostics were removed after identifying this
|
||||
path. No timeout increase, hidden request retry or production fix is included.
|
||||
|
||||
Resource samples did not show pool waiters, but cannot rule out short bursts.
|
||||
Redis slow-command timings and latency events are now captured before fixture
|
||||
cleanup, alongside Redis CPU, memory and command counters, to distinguish server
|
||||
execution cost from transport or scheduling delays. Slow-command summaries omit
|
||||
command arguments. These diagnostics belong only to the disposable fixture.
|
||||
Until the cause is established and qualified, this workload establishes no
|
||||
supported sustained throughput or production alert thresholds.
|
||||
|
||||
The timing-instrumented Windows-JVM run failed at round 480, with no commands
|
||||
at or above the configured one-millisecond Redis slow-log threshold and no
|
||||
latency-monitor events. Redis reported about 93 microseconds per `EVALSHA` on
|
||||
average. This weakens a slow-Lua explanation but does not identify the delayed
|
||||
component or exclude time spent outside command execution.
|
||||
|
||||
## Linux comparison
|
||||
|
||||
`--node-image eclipse-temurin:21-jre` optionally runs each JVM in its own Linux
|
||||
container on an owned Docker network shared with Redis. The HTTP driver remains
|
||||
on the host. Only loopback HTTP ports are published. Compiled fixture classes
|
||||
and copied dependency JARs are mounted read-only; Redis remains disposable.
|
||||
The existing loopback defaults remain unchanged when this option is absent.
|
||||
Container mode records Docker client PIDs separately from container identities.
|
||||
It removes the Windows-JVM-to-published-Redis-port path, but also changes the JVM
|
||||
platform and scheduling environment; it is a diagnostic comparison, not proof
|
||||
of a specific operating-system or Docker defect. Cleanup includes every owned
|
||||
node container and the owned network.
|
||||
|
||||
`--churn-every 20` departs all tabs belonging to the last user in each tenant
|
||||
every 20 rounds. Every departed lease must reject a later renewal with HTTP 409
|
||||
and `PRESENCE_LEASE_EXPIRED` on another node. All surviving nodes must show that
|
||||
user absent on both records, then show the original exact membership after
|
||||
fresh, distinct leases are acquired and activated. Only this explicit rejoin
|
||||
path acquires new leases; the node-crash path preserves existing ones.
|
||||
|
||||
`--pause-redis` adds a controlled failure phase after the ordinary rounds. It
|
||||
freezes only the owned Redis container, sends up to 64 concurrent renewal
|
||||
requests, and requires HTTP 503 within an eight-second fixture bound. Snapshots
|
||||
must also fail explicitly rather than return an empty room. Redis is unpaused
|
||||
in `finally`; once reachable, higher-sequence renewals must recover the same
|
||||
leases and exact membership. The test requires observed pool waiters and rejects
|
||||
sampled active counts above the configured maximum. It does not retry failed
|
||||
requests invisibly or raise production timeouts.
|
||||
|
||||
The [Linux Redis-stall receipt](results/linux-redis-stall.json) passed with three
|
||||
nodes, two tenants, 24 users and 96 tab leases. All 96 stalled renewals and all
|
||||
three node snapshots returned the expected unavailable response. Existing
|
||||
leases recovered after Redis resumed, and cleanup passed. This proves bounded
|
||||
failure and recovery for an injected stall; it does not explain the intermittent
|
||||
Windows timeout or establish a saturation throughput threshold.
|
||||
|
||||
```powershell
|
||||
python docs/performance/presence_nodes.py --java java --node-image eclipse-temurin:21-jre --nodes 3 --tenants 2 --users 12 --tabs 4 --rounds 1000
|
||||
```
|
||||
|
||||
The first Linux comparison completed 1,000 rounds in 250.811 seconds: 96,000
|
||||
renewals and 12,000 snapshots, with exact membership/editor assertions and
|
||||
cleanup passing. Renewal p95/p99 were 15.331/17.760 ms. The sampled active pool
|
||||
peak was five of eight, with no sampled waiters. This one run is separate from
|
||||
the unresolved Windows failures and does not establish a production SLA.
|
||||
|
||||
`--crash-round 50` abruptly kills the first node before that round, then sends
|
||||
all existing leases through surviving nodes. It does not acquire replacement
|
||||
leases or hide failed requests with retries. Exact tenant membership, duplicate
|
||||
tabs and editor counts must remain correct on every surviving node. This tests
|
||||
HTTP lease continuity after process loss, not browser/SSE reconnect, ingress
|
||||
failover, database durability or a Redis outage.
|
||||
|
||||
The Linux crash run passed 100 rounds (9,600 renewals and 996 snapshots) in
|
||||
25.148 seconds. The first JVM exited with code 137 at round 50; the remaining
|
||||
two retained all issued leases and expected membership. Cleanup passed.
|
||||
|
||||
A [combined churn and node-loss run](results/linux-node-crash-churn.json) passed
|
||||
100 rounds with the first node killed at round 50 and churn every 20 rounds.
|
||||
Five cycles rejected all 40 departed handles and checked absence/restored
|
||||
membership across both tenants and records on every surviving node. Cleanup
|
||||
passed. This is bounded HTTP lifecycle qualification; browser reconnect and
|
||||
missed-event behavior remain separate requirements.
|
||||
|
||||
Recorded receipts retain both successful and failing evidence:
|
||||
|
||||
- [Windows timeout with Redis diagnostics](results/windows-timeout.json)
|
||||
- [Linux 1,000-round run](results/linux-1000-rounds.json)
|
||||
- [Linux node crash](results/linux-node-crash.json)
|
||||
|
||||
Only the Java executable path is normalized in these receipts. The Linux image
|
||||
used for these runs was
|
||||
`eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99`.
|
||||
|
||||
## Browser propagation baseline
|
||||
|
||||
`browser_subscription_affinity.mjs FRONTEND_REPO BASE_URL RESULT_PATH` verifies
|
||||
the current owning-node subscription contract against the two-node fixture. It
|
||||
uses the connection ID from the real browser's successful subscription request,
|
||||
then preserves the same session cookies while changing only the test routing
|
||||
cookie. The [published-artifact receipt](results/browser-subscription-affinity.json)
|
||||
records owner 200, other node 404, owner 200, with the requested channel active
|
||||
on both successful responses and the unsaved draft retained. This proves why
|
||||
subscription requests need owning-node routing even when presence state is shared.
|
||||
It does not test the deployed Traefik cookie, HTTPS, buffering or failover.
|
||||
The packaged runner accepts this script through `--affinity-script`.
|
||||
|
||||
`browser_presence_latency.mjs` measures Chromium field-focus changes until a
|
||||
different editor sees the corresponding remote marker. Supply a caller-owned
|
||||
two-node loopback fixture serving the published frontend with `alice`/`admin`
|
||||
mock identities and the acceptance router's `presence_node=first/second`
|
||||
affinity cookies:
|
||||
|
||||
```powershell
|
||||
node docs/performance/browser_presence_latency.mjs /path/to/frontend http://localhost:PORT result.json 25
|
||||
```
|
||||
|
||||
Use `presence_router.mjs FIRST_NODE_PORT SECOND_NODE_PORT ROUTER_PORT` for this
|
||||
test. It binds only to loopback and adds a test-only POST endpoint that severs
|
||||
first-node SSE responses. Never deploy this fault-injection router. The packaged
|
||||
acceptance fixture accepts it through `--router-script` alongside `--latency-script`.
|
||||
|
||||
The test verifies the target marker is absent before every change, the old
|
||||
marker disappears afterward, both real SSE streams opened successfully,
|
||||
issued-lease writes reached distinct nodes, and unsaved drafts survive. API
|
||||
responses are not mocked. The fixture owns backend/router/Redis cleanup; this
|
||||
script owns and closes its browser.
|
||||
|
||||
After timing the normal transitions, the script takes the observer offline and
|
||||
explicitly severs its existing SSE response through the fixture router. It requires
|
||||
an observed stream failure, a failed offline fetch, three acknowledged sender focus
|
||||
changes, and absence of the new marker while disconnected. After network restoration
|
||||
it requires a new successful SSE response and the current marker with the previous
|
||||
marker removed, without reload or loss of either unsaved draft. Recovery may use
|
||||
the shipped reconnect and polling behavior; this does not prove event replay.
|
||||
|
||||
The [disconnect/recovery receipt](results/browser-reconnect.json) passed against
|
||||
the public RC37 JAR: one stream severed, three missed changes, recovery in 1,858 ms,
|
||||
both drafts preserved. The accompanying six-user and Redis admin checks passed;
|
||||
fixture cleanup passed. This is one Chromium disconnect case without concurrent
|
||||
load, not a reconnect storm, lease-expiry test, or production recovery objective.
|
||||
|
||||
Append `load` to the browser command (or use `--latency-load` on the packaged
|
||||
fixture) to add 16 concurrent HTTP lease workers: four mock identities with four
|
||||
leases each on the same record, split between both nodes. Each worker renews
|
||||
sequentially with a 100 ms pause after each response. The browser observations
|
||||
are spread over at least 60 seconds; every observation interval must include
|
||||
completed background renewals. The workload continues during disconnect/reconnect.
|
||||
Any request or lease-cleanup failure fails the test. Responses are disposed after
|
||||
reading, and workers stop before the contexts close. The receipt reports completed
|
||||
renewals, observed node counts, elapsed time and any errors. These are HTTP load
|
||||
workers, not 16 additional browser tabs or distinct people.
|
||||
|
||||
The [bounded load/reconnect receipt](results/browser-load-reconnect.json) passed
|
||||
9,046 renewals over 69.728 seconds, with no worker or lease-release errors.
|
||||
Across 25 observed focus transitions, p50/p95/p99 were 32.384/85.391/785.495 ms;
|
||||
p99 is the maximum of this small sample, not a reliable tail-latency estimate.
|
||||
The observer recovered in 1,855.887 ms after missing three updates while load
|
||||
continued. Both drafts survived. Six-user and Redis admin acceptance also passed
|
||||
against the public RC37 JAR; fixture cleanup passed. An initial run failed only
|
||||
because the new load helper used the wrong lease-release endpoint; that receipt
|
||||
is retained in the local packaged fixture history, not counted as a passing run.
|
||||
|
||||
The [25-transition receipt](results/browser-focus-25.json) passed against the
|
||||
downloaded public RC37 JAR and published backend snapshots. Focus-to-observed-DOM
|
||||
p50/p95/p99 were 31.648/33.078/36.092 ms; p99 is the maximum in this small sample.
|
||||
There were no page errors or failed lease responses. Six-user presence and Redis
|
||||
admin browser checks also passed in the same fixture run; fixture cleanup passed.
|
||||
|
||||
This baseline includes browser automation, UI debounce, network, invalidation
|
||||
and rendering. It is not a server-only transport measurement, an SSE-only causal
|
||||
proof, an under-load result, or a production latency objective. The separate load
|
||||
receipt above covers one bounded workload and disconnect scenario; larger loads,
|
||||
reconnect storms and deployment-specific behavior remain unqualified.
|
||||
|
||||
Remaining qualification includes longer soak and larger workloads, container
|
||||
resource limits, reconnect storms and the actual deployment's capacity and alert
|
||||
thresholds. The receipts above cover specific bounded HTTP, browser, saturation,
|
||||
churn, missed-update and process-crash cases, not every combination. The native
|
||||
Windows intermittent Redis read timeout remains unresolved. Loopback HTTP latency must not be reported
|
||||
as browser propagation or a production SLA. No deployed capacity threshold is
|
||||
established by this fixture.
|
||||
@@ -0,0 +1,145 @@
|
||||
// Real UI observations against a caller-owned two-node acceptance fixture.
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { startPresenceLoad } from './browser_presence_load.mjs';
|
||||
|
||||
const [frontendRepo, base, output, countValue = '25', loadMode] = process.argv.slice(2);
|
||||
if (loadMode && loadMode !== 'load') throw new Error('Optional final argument must be load');
|
||||
const samples = Number(countValue);
|
||||
if (!frontendRepo || !base || !output || !Number.isInteger(samples) || samples < 5 || samples > 100) {
|
||||
throw new Error('Expected frontend repo, loopback base URL, result path and 5..100 samples');
|
||||
}
|
||||
if (!['localhost', '127.0.0.1'].includes(new URL(base).hostname)) {
|
||||
throw new Error('Only the disposable loopback fixture is supported');
|
||||
}
|
||||
const require = createRequire(resolve(frontendRepo, 'package.json'));
|
||||
const { chromium, expect } = require('@playwright/test');
|
||||
const result = { status: 'FAIL', samples: [], streams: [], requests: [], errors: [],
|
||||
scope: 'Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load' };
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
let load;
|
||||
try {
|
||||
async function editor(user, node) {
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([
|
||||
{ name: 'collab_dev_user', value: user, url: base },
|
||||
{ name: 'presence_node', value: node, url: base },
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => result.errors.push(error.message));
|
||||
page.on('response', response => {
|
||||
const path = new URL(response.url()).pathname;
|
||||
if (path.endsWith('/realtime/stream')) {
|
||||
result.streams.push({ user, status: response.status(), type: response.headers()['content-type'] });
|
||||
}
|
||||
if (path.includes('/presence/leases')) {
|
||||
result.requests.push({ user, status: response.status(), node: response.headers()['x-acceptance-node'] });
|
||||
}
|
||||
});
|
||||
await page.goto(`${base}/app/person/1/edit?createMockSession=true`);
|
||||
await expect(page.locator('[data-qqq-id="firstName"]')).toBeVisible();
|
||||
return page;
|
||||
}
|
||||
const observer = await editor('alice', 'first');
|
||||
const sender = await editor('admin', 'second');
|
||||
await observer.locator('[data-qqq-id="firstName"]').fill('Retained observer draft');
|
||||
await sender.locator('[data-qqq-id="firstName"]').fill('Retained sender draft');
|
||||
await expect(observer.locator('[data-presence-field="firstName"]')).toContainText('Demo Administrator');
|
||||
if (loadMode) {
|
||||
load = await startPresenceLoad(browser, base, expect);
|
||||
result.load = load.state;
|
||||
result.scope = 'Chromium focus-to-remote-DOM and reconnect under 16 concurrent HTTP lease workers; loopback, four background identities';
|
||||
}
|
||||
for (let index = 0; index < samples; index++) {
|
||||
const previousCompleted = load?.state.completed;
|
||||
if (load) await delay(60000 / samples);
|
||||
const field = index % 2 === 0 ? 'lastName' : 'firstName';
|
||||
const previous = field === 'firstName' ? 'lastName' : 'firstName';
|
||||
await expect(observer.locator(`[data-presence-field="${field}"]`)).not.toContainText('Demo Administrator');
|
||||
const before = performance.now();
|
||||
await sender.locator(`[data-qqq-id="${field}"]`).focus();
|
||||
await expect(observer.locator(`[data-presence-field="${field}"]`)).toContainText('Demo Administrator');
|
||||
result.samples.push({ field, propagation_ms: performance.now() - before });
|
||||
await expect(observer.locator(`[data-presence-field="${previous}"]`)).not.toContainText('Demo Administrator');
|
||||
if (load) {
|
||||
expect(load.state.errors).toEqual([]);
|
||||
expect(load.state.completed).toBeGreaterThan(previousCompleted);
|
||||
}
|
||||
}
|
||||
// Drop a real browser connection, miss multiple focus updates, then converge
|
||||
// through the shipped reconnect/polling behavior without reloading the form.
|
||||
const oldField = samples % 2 === 0 ? 'firstName' : 'lastName';
|
||||
const newField = oldField === 'firstName' ? 'lastName' : 'firstName';
|
||||
const streamsBefore = result.streams.filter(stream => stream.user === 'alice').length;
|
||||
result.reconnect = { status: 'FAIL', missed_changes: 0 };
|
||||
const interrupted = observer.waitForEvent('requestfailed', {
|
||||
predicate: request => new URL(request.url()).pathname.endsWith('/realtime/stream'),
|
||||
timeout: 10000,
|
||||
});
|
||||
await observer.context().setOffline(true);
|
||||
const disconnect = await fetch(`${base}/__acceptance/disconnect-first`, { method: 'POST' });
|
||||
expect(disconnect.status).toBe(200);
|
||||
result.reconnect.disconnected_streams = (await disconnect.json()).disconnected;
|
||||
expect(result.reconnect.disconnected_streams).toBeGreaterThan(0);
|
||||
const failedStream = await interrupted;
|
||||
result.reconnect.stream_failure = failedStream.failure()?.errorText;
|
||||
const offlineFetch = await observer.evaluate(async () => {
|
||||
try {
|
||||
await fetch(`/app/?offlineProbe=${Date.now()}`, { cache: 'no-store' });
|
||||
return false;
|
||||
} catch { return true; }
|
||||
});
|
||||
expect(offlineFetch).toBe(true);
|
||||
for (const field of [newField, oldField, newField]) {
|
||||
const renewed = sender.waitForResponse(response =>
|
||||
new URL(response.url()).pathname.endsWith('/presence/leases/renew') &&
|
||||
response.request().method() === 'POST' && response.status() === 200 &&
|
||||
response.request().postDataJSON()?.fieldName === field);
|
||||
await sender.locator(`[data-qqq-id="${field}"]`).focus();
|
||||
await renewed;
|
||||
result.reconnect.missed_changes++;
|
||||
}
|
||||
await expect(observer.locator(`[data-presence-field="${newField}"]`)).not.toContainText('Demo Administrator');
|
||||
const resumed = performance.now();
|
||||
await observer.context().setOffline(false);
|
||||
await expect.poll(() => result.streams.filter(stream => stream.user === 'alice' &&
|
||||
stream.status === 200 && stream.type?.includes('text/event-stream')).length,
|
||||
{ timeout: 30000 }).toBeGreaterThan(streamsBefore);
|
||||
await expect(observer.locator(`[data-presence-field="${newField}"]`)).toContainText('Demo Administrator', { timeout: 30000 });
|
||||
await expect(observer.locator(`[data-presence-field="${oldField}"]`)).not.toContainText('Demo Administrator');
|
||||
result.reconnect.recovery_ms = performance.now() - resumed;
|
||||
result.reconnect.status = 'PASS';
|
||||
if (load) {
|
||||
await load.stop();
|
||||
expect(load.state.errors).toEqual([]);
|
||||
expect(load.state.elapsed_ms).toBeGreaterThanOrEqual(60000);
|
||||
expect(load.state.nodes.first).toBeGreaterThan(0);
|
||||
expect(load.state.nodes.second).toBeGreaterThan(0);
|
||||
load = undefined;
|
||||
}
|
||||
await expect(observer.locator('[data-qqq-id="firstName"]')).toHaveValue('Retained observer draft');
|
||||
await expect(sender.locator('[data-qqq-id="firstName"]')).toHaveValue('Retained sender draft');
|
||||
for (const user of ['alice', 'admin']) {
|
||||
expect(result.streams.some(stream => stream.user === user && stream.status === 200 && stream.type?.includes('text/event-stream'))).toBe(true);
|
||||
}
|
||||
expect(result.requests.some(request => request.user === 'alice' && request.node === 'first')).toBe(true);
|
||||
expect(result.requests.some(request => request.user === 'admin' && request.node === 'second')).toBe(true);
|
||||
expect(result.requests.every(request => request.status === 200)).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
const ordered = result.samples.map(sample => sample.propagation_ms).sort((a, b) => a - b);
|
||||
result.percentiles_ms = Object.fromEntries([['p50', .5], ['p95', .95], ['p99', .99], ['max', 1]]
|
||||
.map(([name, quantile]) => [name, ordered[Math.ceil(ordered.length * quantile) - 1]]));
|
||||
result.status = 'PASS';
|
||||
} catch (error) {
|
||||
result.failure = String(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (load) await load.stop();
|
||||
await browser.close();
|
||||
await writeFile(output, JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Bounded real HTTP load using caller-owned authenticated browser contexts.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
export async function startPresenceLoad(browser, base, expect) {
|
||||
const state = { workers: 16, completed: 0, errors: [], nodes: {}, elapsed_ms: 0 };
|
||||
const contexts = [];
|
||||
const leases = [];
|
||||
let running = true;
|
||||
let stopped = false;
|
||||
let tasks = [];
|
||||
const began = performance.now();
|
||||
const endpoint = `${base}/qqq/v1/qbit/collaboration/presence/leases`;
|
||||
async function post(context, path, data) {
|
||||
const response = await context.request.post(endpoint + path, { data, timeout: 10000 });
|
||||
try {
|
||||
expect(response.status()).toBe(200);
|
||||
const node = response.headers()['x-acceptance-node'];
|
||||
state.nodes[node] = (state.nodes[node] ?? 0) + 1;
|
||||
return await response.json();
|
||||
} finally { await response.dispose(); }
|
||||
}
|
||||
async function stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
running = false;
|
||||
await Promise.all(tasks);
|
||||
state.elapsed_ms = performance.now() - began;
|
||||
for (const lease of leases) {
|
||||
try {
|
||||
const response = await lease.context.request.delete(`${base}/qqq/v1/qbit/collaboration/presence`, {
|
||||
data: { ...lease.target, connectionId: lease.id, sequence: ++lease.sequence }, timeout: 10000,
|
||||
});
|
||||
try { expect(response.status()).toBe(200); } finally { await response.dispose(); }
|
||||
} catch (error) { state.errors.push(String(error)); }
|
||||
}
|
||||
for (const context of contexts) await context.close();
|
||||
}
|
||||
try {
|
||||
for (const [index, user] of ['bob', 'carol', 'dave', 'erin'].entries()) {
|
||||
const context = await browser.newContext();
|
||||
contexts.push(context);
|
||||
await context.addCookies([
|
||||
{ name: 'collab_dev_user', value: user, url: base },
|
||||
{ name: 'presence_node', value: index % 2 ? 'second' : 'first', url: base },
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${base}/app/person/1?createMockSession=true`);
|
||||
await expect(page.locator('[data-qqq-id="record-view-title-person"]')).toBeVisible();
|
||||
await page.close();
|
||||
for (let tab = 0; tab < 4; tab++) {
|
||||
const target = { table: 'person', pk: '1' };
|
||||
const opened = await post(context, '', target);
|
||||
expect(typeof opened.connectionId).toBe('string');
|
||||
leases.push({ context, target, id: opened.connectionId, sequence: 0, completed: 0 });
|
||||
}
|
||||
}
|
||||
tasks = leases.map(async lease => {
|
||||
try {
|
||||
while (running) {
|
||||
await post(lease.context, '/renew', { ...lease.target, connectionId: lease.id,
|
||||
sequence: ++lease.sequence, mode: 'VIEWING' });
|
||||
lease.completed++;
|
||||
state.completed++;
|
||||
await delay(100);
|
||||
}
|
||||
} catch (error) { state.errors.push(String(error)); running = false; }
|
||||
});
|
||||
await expect.poll(() => leases.filter(lease => lease.completed > 0).length).toBe(16);
|
||||
return { state, stop };
|
||||
} catch (error) {
|
||||
await stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Verify owning-node routing with real published UI and backend artifacts.
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
const [frontendRepo, base, output] = process.argv.slice(2);
|
||||
if (!frontendRepo || !output || !['localhost', '127.0.0.1'].includes(new URL(base).hostname)) {
|
||||
throw new Error('Expected frontend repo, owned loopback URL and result path');
|
||||
}
|
||||
const require = createRequire(resolve(frontendRepo, 'package.json'));
|
||||
const { chromium, expect } = require('@playwright/test');
|
||||
const result = { status: 'FAIL', checks: [], errors: [] };
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([
|
||||
{ name: 'collab_dev_user', value: 'alice', url: base },
|
||||
{ name: 'presence_node', value: 'first', url: base },
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => result.errors.push(error.message));
|
||||
const subscribed = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname.endsWith('/realtime/subscriptions') && response.status() === 200);
|
||||
await page.goto(`${base}/app/person/1/edit?createMockSession=true`);
|
||||
await expect(page.locator('[data-qqq-id="firstName"]')).toBeVisible();
|
||||
await page.locator('[data-qqq-id="firstName"]').fill('Keep this unsaved draft');
|
||||
const response = await subscribed;
|
||||
expect(response.headers()['x-acceptance-node']).toBe('first');
|
||||
const { connectionId } = response.request().postDataJSON();
|
||||
expect(typeof connectionId).toBe('string');
|
||||
for (const [node, expected] of [['first', 200], ['second', 404], ['first', 200]]) {
|
||||
// Keep the same session/user cookies and change only the test routing cookie.
|
||||
const cookies = (await context.cookies(base)).map(cookie =>
|
||||
`${cookie.name}=${cookie.name === 'presence_node' ? node : cookie.value}`).join('; ');
|
||||
const changed = await context.request.post(`${base}/qqq/v1/realtime/subscriptions`, {
|
||||
headers: { Cookie: cookies },
|
||||
data: { connectionId, add: ['record:person:1'], remove: [] }, timeout: 10000,
|
||||
});
|
||||
try {
|
||||
result.checks.push({ node: changed.headers()['x-acceptance-node'], status: changed.status() });
|
||||
expect(changed.headers()['x-acceptance-node']).toBe(node);
|
||||
expect(changed.status()).toBe(expected);
|
||||
if (expected === 200) {
|
||||
const body = await changed.json();
|
||||
expect(body.active).toContain('record:person:1');
|
||||
expect(body.refused ?? []).toEqual([]);
|
||||
}
|
||||
} finally { await changed.dispose(); }
|
||||
}
|
||||
await expect(page.locator('[data-qqq-id="firstName"]')).toHaveValue('Keep this unsaved draft');
|
||||
expect(result.errors).toEqual([]);
|
||||
result.status = 'PASS';
|
||||
} catch (error) {
|
||||
result.failure = String(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await writeFile(output, JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Bounded HTTP presence workload across independent, disposable JVMs.
|
||||
|
||||
This measures loopback HTTP service latency, not browser propagation or a
|
||||
production capacity limit. Run test-compile and dependency:build-classpath with
|
||||
test scope and output target/capacity-classpath.txt before invoking this script.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
|
||||
def port():
|
||||
"""Choose a loopback port; child startup detects a competing bind."""
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
def request(node, user, path, body=None, tab=0, method=None, expected=200):
|
||||
"""Use real HTTP and the explicit mock identity; no tenant parameter."""
|
||||
separator = "&" if "?" in path else "?"
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{node}/qqq/v1/qbit/collaboration{path}{separator}user={user}",
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json",
|
||||
"Cookie": f"sessionUUID={user}-capacity-{tab}"})
|
||||
try:
|
||||
response = urllib.request.urlopen(req, timeout=10)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == expected:
|
||||
with error:
|
||||
return json.load(error)
|
||||
with error:
|
||||
detail = error.read(4096).decode(errors="replace")
|
||||
raise RuntimeError(f"HTTP {error.code} node={node} user={user} path={path}: {detail}") from error
|
||||
with response:
|
||||
if response.status != expected:
|
||||
raise AssertionError(f"Unexpected status {response.status}")
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def distribution(values):
|
||||
"""Nearest-rank milliseconds, with no discarded slow or failed samples."""
|
||||
ordered = sorted(values)
|
||||
return {"samples": len(ordered), **{
|
||||
name: ordered[max(0, math.ceil(len(ordered) * fraction) - 1)]
|
||||
for name, fraction in (("p50_ms", .5), ("p95_ms", .95), ("p99_ms", .99), ("max_ms", 1))}}
|
||||
|
||||
|
||||
def main():
|
||||
"""Own child JVMs/container and retain results even when a check fails."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--java", required=True)
|
||||
parser.add_argument("--node-image", help="Optional Linux Java21 image; isolate Redis traffic on an owned Docker network")
|
||||
parser.add_argument("--nodes", type=int, default=3)
|
||||
parser.add_argument("--tenants", type=int, default=2)
|
||||
parser.add_argument("--users", type=int, default=6)
|
||||
parser.add_argument("--tabs", type=int, default=4)
|
||||
parser.add_argument("--rounds", type=int, default=10)
|
||||
parser.add_argument("--workers", type=int, default=8)
|
||||
parser.add_argument("--crash-round", type=int, default=0, help="Kill the first node before this round; zero disables")
|
||||
parser.add_argument("--churn-every", type=int, default=0, help="Leave/rejoin the last user's tabs every N rounds; zero disables")
|
||||
parser.add_argument("--pause-redis", action="store_true", help="After the workload, freeze owned Redis and verify bounded failures/recovery")
|
||||
args = parser.parse_args()
|
||||
for name, lower, upper in (("nodes", 2, 8), ("tenants", 2, 8), ("users", 2, 100),
|
||||
("tabs", 2, 8), ("rounds", 1, 1000), ("workers", 1, 64)):
|
||||
if not lower <= getattr(args, name) <= upper:
|
||||
parser.error(f"{name} must be {lower}..{upper}")
|
||||
if args.crash_round and not 2 <= args.crash_round <= args.rounds:
|
||||
parser.error("crash-round must be zero or 2..rounds")
|
||||
if args.churn_every and not 1 <= args.churn_every <= args.rounds:
|
||||
parser.error("churn-every must be zero or 1..rounds")
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
output = repo / "target" / ("capacity-nodes-" + uuid.uuid4().hex[:10])
|
||||
output.mkdir(parents=True)
|
||||
classpath = os.pathsep.join([str(repo / "target/test-classes"), str(repo / "target/classes"),
|
||||
(repo / "target/capacity-classpath.txt").read_text().strip()])
|
||||
processes, logs = [], []
|
||||
container = "qqq-capacity-" + uuid.uuid4().hex[:12]
|
||||
network = container + "-net"
|
||||
node_containers = []
|
||||
network_created = False
|
||||
result = {"status": "FAIL", "parameters": vars(args), "scope": "loopback HTTP, mock identities, memory records"}
|
||||
try:
|
||||
network_args = []
|
||||
if args.node_image:
|
||||
result["node_image_id"] = subprocess.check_output(
|
||||
["docker", "image", "inspect", args.node_image, "--format", "{{.Id}}"], text=True).strip()
|
||||
subprocess.run(["docker", "network", "create", network], check=True, capture_output=True)
|
||||
network_created = True
|
||||
network_args = ["--network", network]
|
||||
dependencies = output / "dependencies"
|
||||
dependencies.mkdir()
|
||||
linux_classpath = ["/fixture/target/test-classes", "/fixture/target/classes"]
|
||||
for index, dependency in enumerate((repo / "target/capacity-classpath.txt").read_text().strip().split(os.pathsep)):
|
||||
destination = dependencies / f"{index}.jar"
|
||||
shutil.copyfile(dependency, destination)
|
||||
linux_classpath.append("/fixture/" + destination.relative_to(repo).as_posix())
|
||||
classpath = ":".join(linux_classpath)
|
||||
subprocess.run(["docker", "run", "-d", "--name", container, "-p", "127.0.0.1::6379",
|
||||
*network_args,
|
||||
"redis:7.4-alpine", "redis-server", "--slowlog-log-slower-than", "1000",
|
||||
"--slowlog-max-len", "128", "--latency-monitor-threshold", "1"], check=True, capture_output=True)
|
||||
redis_port = int(subprocess.check_output(["docker", "port", container, "6379"], text=True).strip().rsplit(":", 1)[1])
|
||||
nodes = [port() for _ in range(args.nodes)]
|
||||
if len(set(nodes)) != len(nodes):
|
||||
raise RuntimeError("Port allocation collision; no nodes started")
|
||||
for node in nodes:
|
||||
argfile = output / f"node-{node}.args"
|
||||
connection_properties = (f"-Dcollab.capacity.redisHost={container}\n-Dcollab.dev.bindAddress=0.0.0.0\n"
|
||||
if args.node_image else "")
|
||||
argfile.write_text(f'-Xmx384m\n-Dcollab.capacity.tenants={args.tenants}\n-Dcollab.capacity.users={args.users}\n'
|
||||
+ connection_properties +
|
||||
'-cp\n"' + classpath.replace("\\", "/") + '"\n'
|
||||
'com.kingsrook.qbits.collaboration.devserver.CapacityPresenceDevServer\n'
|
||||
+ (f'8080\n6379\n' if args.node_image else f'{node}\n{redis_port}\n'), encoding="utf-8")
|
||||
log = (output / f"node-{node}.log").open("w")
|
||||
logs.append(log)
|
||||
command = [args.java, "@" + str(argfile)]
|
||||
if args.node_image:
|
||||
node_container = f"{container}-node-{node}"
|
||||
node_containers.append(node_container)
|
||||
command = ["docker", "run", "--rm", "--name", node_container, "--network", network,
|
||||
"-p", f"127.0.0.1:{node}:8080", "--mount", f"type=bind,source={repo},target=/fixture,readonly",
|
||||
args.node_image, "java", "@/fixture/" + argfile.relative_to(repo).as_posix()]
|
||||
processes.append(subprocess.Popen(command, cwd=repo,
|
||||
stdout=log, stderr=subprocess.STDOUT,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)))
|
||||
deadline = time.monotonic() + 120
|
||||
for node, process in zip(nodes, processes):
|
||||
while "CAPACITY_PRESENCE_READY " not in (output / f"node-{node}.log").read_text(errors="replace"):
|
||||
if process.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError(f"Node {node} failed startup; inspect {output}")
|
||||
time.sleep(.2)
|
||||
actors = []
|
||||
for tenant in range(args.tenants):
|
||||
for user in range(args.users):
|
||||
for tab in range(args.tabs):
|
||||
identity = f"load-t{tenant}-u{user}"
|
||||
record = str(1 + tab % 2)
|
||||
node = nodes[len(actors) % len(nodes)]
|
||||
lease = request(node, identity, "/presence/leases", {"table": "person", "pk": record}, tab)["connectionId"]
|
||||
actors.append((identity, record, tab, lease, user))
|
||||
renewals, snapshots = [], []
|
||||
|
||||
def check_rooms(excluded=None):
|
||||
"""Check every tenant/record on every surviving node, including absence."""
|
||||
for tenant in range(args.tenants):
|
||||
expected_users = {f"load-t{tenant}-u{user}" for user in range(args.users) if user != excluded}
|
||||
for node in nodes:
|
||||
for record in ("1", "2"):
|
||||
before = time.perf_counter()
|
||||
viewers = request(node, f"load-t{tenant}-u0", f"/presence?table=person&pk={record}")["viewers"]
|
||||
snapshots.append((time.perf_counter() - before) * 1000)
|
||||
actual = {viewer["userId"] for viewer in viewers}
|
||||
if actual != expected_users or len(viewers) != len(expected_users):
|
||||
raise AssertionError(f"Tenant isolation/deduplication mismatch: {actual} != {expected_users}")
|
||||
if sum(viewer["mode"] == "EDITING" for viewer in viewers) != sum(user != excluded for user in range(2)):
|
||||
raise AssertionError("Unexpected editor count")
|
||||
|
||||
def renew(item, sequence):
|
||||
"""Keep each tab sequential while moving its writes between nodes."""
|
||||
index, (identity, record, tab, lease, user) = item
|
||||
before = time.perf_counter()
|
||||
request(nodes[(index + sequence) % len(nodes)], identity, "/presence/leases/renew",
|
||||
{"connectionId": lease, "table": "person", "pk": record,
|
||||
"mode": "EDITING" if user < 2 else "VIEWING", "sequence": sequence}, tab)
|
||||
return (time.perf_counter() - before) * 1000
|
||||
|
||||
started = time.perf_counter()
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as workers:
|
||||
for sequence in range(1, args.rounds + 1):
|
||||
result["current_round"] = sequence
|
||||
if sequence == args.crash_round:
|
||||
crashed = nodes.pop(0)
|
||||
if args.node_image:
|
||||
subprocess.run(["docker", "kill", node_containers[0]], check=True, capture_output=True)
|
||||
else:
|
||||
processes[0].kill()
|
||||
processes[0].wait(timeout=10)
|
||||
result["crash"] = {"round": sequence, "http_port": crashed,
|
||||
"surviving_nodes": len(nodes), "process_exit": processes[0].returncode}
|
||||
renewals.extend(workers.map(lambda item: renew(item, sequence), enumerate(actors)))
|
||||
check_rooms()
|
||||
if args.churn_every and sequence % args.churn_every == 0:
|
||||
departing = [(index, actor) for index, actor in enumerate(actors) if actor[4] == args.users - 1]
|
||||
for index, (identity, record, tab, lease, user) in departing:
|
||||
request(nodes[index % len(nodes)], identity, "/presence",
|
||||
{"connectionId": lease, "table": "person", "pk": record, "sequence": sequence + 1}, tab, method="DELETE")
|
||||
response = request(nodes[(index + 1) % len(nodes)], identity, "/presence/leases/renew",
|
||||
{"connectionId": lease, "table": "person", "pk": record,
|
||||
"mode": "VIEWING", "sequence": sequence + 2}, tab, expected=409)
|
||||
if response.get("code") != "PRESENCE_LEASE_EXPIRED":
|
||||
raise AssertionError("Departed lease was not rejected as expired")
|
||||
check_rooms(excluded=args.users - 1)
|
||||
for index, (identity, record, tab, lease, user) in departing:
|
||||
replacement = request(nodes[(index + 1) % len(nodes)], identity, "/presence/leases",
|
||||
{"table": "person", "pk": record}, tab)["connectionId"]
|
||||
if replacement == lease:
|
||||
raise AssertionError("Rejoin reused departed authority")
|
||||
actors[index] = (identity, record, tab, replacement, user)
|
||||
renew((index, actors[index]), sequence)
|
||||
check_rooms()
|
||||
result["churn_cycles"] = result.get("churn_cycles", 0) + 1
|
||||
result["departed_leases_rejected"] = result.get("departed_leases_rejected", 0) + len(departing)
|
||||
result["completed_rounds"] = sequence
|
||||
if args.pause_redis:
|
||||
failures = []
|
||||
|
||||
def stalled_renew(item):
|
||||
"""A frozen authority must fail explicitly, never acknowledge a write."""
|
||||
index, (identity, record, tab, lease, user) = item
|
||||
before = time.perf_counter()
|
||||
response = request(nodes[index % len(nodes)], identity, "/presence/leases/renew",
|
||||
{"connectionId": lease, "table": "person", "pk": record,
|
||||
"mode": "EDITING" if user < 2 else "VIEWING", "sequence": args.rounds + 1},
|
||||
tab, expected=503)
|
||||
if "viewers" in response or "temporarily unavailable" not in response.get("message", ""):
|
||||
raise AssertionError("Stalled authority did not return the expected unavailable response")
|
||||
duration = (time.perf_counter() - before) * 1000
|
||||
if duration > 8000:
|
||||
raise AssertionError("Unavailable response exceeded the eight-second fixture bound")
|
||||
return duration
|
||||
|
||||
subprocess.run(["docker", "pause", container], check=True, capture_output=True)
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=64) as burst:
|
||||
failures.extend(burst.map(stalled_renew, enumerate(actors)))
|
||||
for node in nodes:
|
||||
response = request(node, "load-t0-u0", "/presence?table=person&pk=1", expected=503)
|
||||
if "viewers" in response:
|
||||
raise AssertionError("Stalled authority was presented as a room snapshot")
|
||||
finally:
|
||||
subprocess.run(["docker", "unpause", container], check=True, capture_output=True)
|
||||
subprocess.run(["docker", "exec", container, "redis-cli", "PING"], check=True, capture_output=True, timeout=10)
|
||||
for item in enumerate(actors):
|
||||
renew(item, args.rounds + 2)
|
||||
check_rooms()
|
||||
result["redis_stall"] = {"rejected_renewals": len(failures), "failed_snapshot_nodes": len(nodes),
|
||||
"response_times": distribution(failures), "same_leases_recovered": True}
|
||||
elapsed = time.perf_counter() - started
|
||||
result.update(status="PASS", seconds=elapsed, renewals=distribution(renewals),
|
||||
snapshots=distribution(snapshots), renewal_throughput=len(renewals) / elapsed,
|
||||
node_pids=[process.pid for process in processes],
|
||||
pid_kind="docker-client" if args.node_image else "JVM", node_containers=node_containers)
|
||||
except Exception as error:
|
||||
result["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
failure_active = sys.exc_info()[0] is not None
|
||||
try:
|
||||
slow = json.loads(subprocess.check_output(
|
||||
["docker", "exec", container, "redis-cli", "--json", "SLOWLOG", "GET", "128"], text=True, timeout=10))
|
||||
result["redis_slow_commands"] = [{"unix_time": entry[1], "duration_us": entry[2], "command": entry[3][0]}
|
||||
for entry in slow]
|
||||
result["redis_latency_events"] = json.loads(subprocess.check_output(
|
||||
["docker", "exec", container, "redis-cli", "--json", "LATENCY", "LATEST"], text=True, timeout=10))
|
||||
for section in ("cpu", "memory", "commandstats", "stats"):
|
||||
value = subprocess.check_output(["docker", "exec", container, "redis-cli", "INFO", section], text=True, timeout=10)
|
||||
(output / f"redis-{section}.txt").write_text(value)
|
||||
parsed = dict(line.split(":", 1) for line in value.splitlines() if ":" in line and not line.startswith("#"))
|
||||
if section == "memory":
|
||||
result["redis_memory"] = {key: int(parsed[key]) for key in
|
||||
("used_memory", "used_memory_peak", "used_memory_rss")}
|
||||
if section == "cpu":
|
||||
result["redis_cpu_seconds_since_start"] = {key: float(parsed[key]) for key in
|
||||
("used_cpu_sys", "used_cpu_user")}
|
||||
except (subprocess.SubprocessError, ValueError, KeyError) as error:
|
||||
result["redis_diagnostic_error"] = type(error).__name__
|
||||
node_cleanup = True
|
||||
for node_container in node_containers:
|
||||
removed = subprocess.run(["docker", "rm", "-f", node_container], capture_output=True)
|
||||
if removed.returncode != 0:
|
||||
remaining = subprocess.run(["docker", "ps", "-aq", "--filter", f"name=^{node_container}$"], capture_output=True, text=True)
|
||||
node_cleanup = node_cleanup and remaining.returncode == 0 and not remaining.stdout.strip()
|
||||
for process in processes:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
for log in logs:
|
||||
log.close()
|
||||
resources = []
|
||||
for node_log in sorted(output.glob("node-*.log")):
|
||||
samples = [json.loads(line.split("CAPACITY_METRIC ", 1)[1])
|
||||
for line in node_log.read_text(errors="replace").splitlines()
|
||||
if line.startswith("CAPACITY_METRIC ")]
|
||||
if samples:
|
||||
first, last = samples[0], samples[-1]
|
||||
wall_ms = last["uptimeMillis"] - first["uptimeMillis"]
|
||||
cpu_ns = last["cpuNanos"] - first["cpuNanos"]
|
||||
resources.append({"node_log": node_log.name, "samples": len(samples),
|
||||
"observed_wall_ms": wall_ms,
|
||||
"cpu_seconds": cpu_ns / 1e9 if first["cpuNanos"] >= 0 else None,
|
||||
"cpu_core_equivalents": cpu_ns / (wall_ms * 1e6) if wall_ms > 0 and first["cpuNanos"] >= 0 else None,
|
||||
**{key + "_sampled_max": max(sample[key] for sample in samples)
|
||||
for key in ("heapUsedBytes", "heapCommittedBytes", "poolActive", "poolIdle", "poolWaiting", "poolMaximum")},
|
||||
"residentBytes_sampled_max": max(sample["residentBytes"] for sample in samples)
|
||||
if all(sample.get("residentBytes", -1) >= 0 for sample in samples) else None})
|
||||
if any(sample["poolActive"] > sample["poolMaximum"] for sample in samples):
|
||||
result.update(status="FAIL", error="Sampled pool active count exceeded configured maximum")
|
||||
result["resources"] = resources
|
||||
if result["status"] == "PASS" and args.node_image and any(node["residentBytes_sampled_max"] is None for node in resources):
|
||||
result.update(status="FAIL", error="Linux resident-memory samples missing")
|
||||
if result["status"] == "PASS" and any(key not in result for key in ("redis_memory", "redis_cpu_seconds_since_start")):
|
||||
result.update(status="FAIL", error="Redis memory/CPU diagnostics missing")
|
||||
if result["status"] == "PASS" and len(resources) != args.nodes:
|
||||
result.update(status="FAIL", error="Missing resource samples for a node")
|
||||
if result["status"] == "PASS" and args.pause_redis and not any(node["poolWaiting_sampled_max"] > 0 for node in resources):
|
||||
result.update(status="FAIL", error="Redis stall did not demonstrate pool waiters")
|
||||
cleanup = subprocess.run(["docker", "rm", "-f", container], capture_output=True)
|
||||
network_cleanup = True
|
||||
if network_created:
|
||||
network_cleanup = subprocess.run(["docker", "network", "rm", network], capture_output=True).returncode == 0
|
||||
result["cleanup"] = cleanup.returncode == 0 and node_cleanup and network_cleanup and all(process.poll() is not None for process in processes)
|
||||
(output / "result.json").write_text(json.dumps(result, indent=2))
|
||||
print(json.dumps(result, indent=2))
|
||||
print(output)
|
||||
if result["status"] == "PASS" and not result["cleanup"]:
|
||||
raise RuntimeError("Workload passed but owned resource cleanup failed")
|
||||
if result["status"] == "FAIL" and not failure_active:
|
||||
raise RuntimeError(result.get("error", "Capacity resource validation failed"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
// Disposable loopback fixture only; never deploy this fault-injection router.
|
||||
import http from 'node:http';
|
||||
|
||||
const [first, second, listen] = process.argv.slice(2).map(Number);
|
||||
if (![first, second, listen].every(port => Number.isInteger(port) && port > 0 && port < 65536)) {
|
||||
throw new Error('Expected first-node, second-node and router ports');
|
||||
}
|
||||
const firstStreams = new Set();
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.method === 'POST' && request.url === '/__acceptance/disconnect-first') {
|
||||
const disconnected = firstStreams.size;
|
||||
for (const stream of firstStreams) stream.destroy();
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ disconnected }));
|
||||
return;
|
||||
}
|
||||
const isSecond = /(?:^|;\s*)presence_node=second(?:;|$)/.test(request.headers.cookie ?? '');
|
||||
const port = isSecond ? second : first;
|
||||
const upstream = http.request({
|
||||
hostname: '127.0.0.1', port, path: request.url, method: request.method,
|
||||
headers: { ...request.headers, host: `127.0.0.1:${port}` },
|
||||
}, incoming => {
|
||||
response.writeHead(incoming.statusCode, {
|
||||
...incoming.headers, 'x-acceptance-node': isSecond ? 'second' : 'first',
|
||||
});
|
||||
if (!isSecond && incoming.headers['content-type']?.includes('text/event-stream')) {
|
||||
firstStreams.add(response);
|
||||
}
|
||||
incoming.pipe(response);
|
||||
});
|
||||
upstream.on('error', () => {
|
||||
if (!response.headersSent) response.writeHead(502);
|
||||
response.end('Acceptance node unavailable');
|
||||
});
|
||||
response.on('close', () => {
|
||||
firstStreams.delete(response);
|
||||
upstream.destroy();
|
||||
});
|
||||
request.pipe(upstream);
|
||||
});
|
||||
server.listen(listen, '127.0.0.1', () => console.log(`PRESENCE_ROUTER_READY ${listen}`));
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close());
|
||||
@@ -0,0 +1,285 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"samples": [
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 36.092099999999846
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.51870000000008
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.758600000000115
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.090299999999843
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.64980000000014
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.648199999999633
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.395500000000084
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.36750000000029
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.601799999999912
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.90599999999995
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.44150000000036
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.186699999999746
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.115800000000036
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.539400000000114
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.188000000000102
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.021200000000135
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 33.07759999999962
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.7176000000004
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.457300000000032
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.920900000000074
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.89130000000023
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.847200000000157
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 30.158300000000054
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.48239999999987
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.337600000000293
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"scope": "Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load",
|
||||
"percentiles_ms": {
|
||||
"p50": 31.648199999999633,
|
||||
"p95": 33.07759999999962,
|
||||
"p99": 36.092099999999846,
|
||||
"max": 36.092099999999846
|
||||
},
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"fixture_cleanup": true,
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records."
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"samples": [
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 36.560600000000704
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.44050000000061
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.50890000000072
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 785.4951000000001
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 85.39069999999992
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.589599999999336
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.756099999998696
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 38.17399999999907
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 30.243099999999686
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.600799999992887
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 29.57220000000234
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.197800000001735
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 33.05130000000645
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.38390000000072
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.52850000000035
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 34.66169999999693
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.258300000001327
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 44.19029999999475
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 36.56189999999333
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.240600000004633
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 29.79899999999907
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.621500000001106
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.66189999999915
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 29.198399999993853
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.931200000006356
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"scope": "Chromium focus-to-remote-DOM and reconnect under 16 concurrent HTTP lease workers; loopback, four background identities",
|
||||
"load": {
|
||||
"workers": 16,
|
||||
"completed": 9046,
|
||||
"errors": [],
|
||||
"nodes": {
|
||||
"first": 4540,
|
||||
"second": 4522
|
||||
},
|
||||
"elapsed_ms": 69728.3579
|
||||
},
|
||||
"reconnect": {
|
||||
"status": "PASS",
|
||||
"missed_changes": 3,
|
||||
"disconnected_streams": 1,
|
||||
"stream_failure": "net::ERR_INCOMPLETE_CHUNKED_ENCODING",
|
||||
"recovery_ms": 1855.8868999999977
|
||||
},
|
||||
"percentiles_ms": {
|
||||
"p50": 32.38390000000072,
|
||||
"p95": 85.39069999999992,
|
||||
"p99": 785.4951000000001,
|
||||
"max": 785.4951000000001
|
||||
},
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"fixture_cleanup": true,
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
|
||||
"fixture_checks": [
|
||||
"browser-six-presence",
|
||||
"browser-redis-admin",
|
||||
"browser-presence-latency"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"samples": [
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 37.25770000000011
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.14499999999998
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.40950000000021
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.52500000000009
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.228200000000015
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.430999999999585
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.493199999999888
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.14539999999988
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 32.11259999999993
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.23129999999992
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.06800000000021
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.109199999999873
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.534299999999803
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.770800000000236
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.654199999999946
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 33.31700000000001
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 33.108600000000024
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.27370000000019
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.452499999999873
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 31.86220000000003
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.734399999999823
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 32.04489999999987
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.051899999999932
|
||||
},
|
||||
{
|
||||
"field": "firstName",
|
||||
"propagation_ms": 30.876300000000356
|
||||
},
|
||||
{
|
||||
"field": "lastName",
|
||||
"propagation_ms": 31.56970000000001
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"type": "text/event-stream;charset=utf-8"
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "alice",
|
||||
"status": 200,
|
||||
"node": "first"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
},
|
||||
{
|
||||
"user": "admin",
|
||||
"status": 200,
|
||||
"node": "second"
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"scope": "Chromium focus-to-remote-DOM observation, including automation and UI debounce; no concurrent load",
|
||||
"reconnect": {
|
||||
"status": "PASS",
|
||||
"missed_changes": 3,
|
||||
"disconnected_streams": 1,
|
||||
"stream_failure": "net::ERR_INCOMPLETE_CHUNKED_ENCODING",
|
||||
"recovery_ms": 1858.2972000000004
|
||||
},
|
||||
"percentiles_ms": {
|
||||
"p50": 31.86220000000003,
|
||||
"p95": 33.770800000000236,
|
||||
"p99": 37.25770000000011,
|
||||
"max": 37.25770000000011
|
||||
},
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"fixture_cleanup": true,
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
|
||||
"fixture_checks": [
|
||||
"browser-six-presence",
|
||||
"browser-redis-admin",
|
||||
"browser-presence-latency"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"checks": [
|
||||
{ "node": "first", "status": 200 },
|
||||
{ "node": "second", "status": 404 },
|
||||
{ "node": "first", "status": 200 }
|
||||
],
|
||||
"errors": [],
|
||||
"fixture_cleanup": true,
|
||||
"fixture_checks": ["browser-six-presence", "browser-redis-admin", "browser-subscription-affinity"],
|
||||
"frontend_jar_sha256": "c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab",
|
||||
"backend_source": "Published core cc3981bb3/collaboration ecff347/Redis7fe5492 snapshots; two independent JVMs, mock identities and memory records.",
|
||||
"scope": "Same browser session and live SSE connection ID; change only test-router affinity. Not deployed Traefik qualification."
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "/path/to/java21",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 50,
|
||||
"tabs": 4,
|
||||
"rounds": 200,
|
||||
"workers": 16,
|
||||
"crash_round": 0,
|
||||
"churn_every": 0,
|
||||
"pause_redis": false
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 200,
|
||||
"completed_rounds": 200,
|
||||
"seconds": 102.56801009993069,
|
||||
"renewals": {
|
||||
"samples": 80000,
|
||||
"p50_ms": 11.325599974952638,
|
||||
"p95_ms": 21.764500066637993,
|
||||
"p99_ms": 25.881099980324507,
|
||||
"max_ms": 1007.4040000326931
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 2400,
|
||||
"p50_ms": 15.130399959161878,
|
||||
"p95_ms": 16.613700077868998,
|
||||
"p99_ms": 19.423600053414702,
|
||||
"max_ms": 28.864299994893372
|
||||
},
|
||||
"renewal_throughput": 779.9702843221492,
|
||||
"node_pids": [
|
||||
105696,
|
||||
4828,
|
||||
117588
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-f58f3ddcf5f3-node-59675",
|
||||
"qqq-capacity-f58f3ddcf5f3-node-59676",
|
||||
"qqq-capacity-f58f3ddcf5f3-node-59677"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793033347,
|
||||
"duration_us": 1432,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033347,
|
||||
"duration_us": 1612,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033340,
|
||||
"duration_us": 1877,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033340,
|
||||
"duration_us": 1313,
|
||||
"command": "ZRANGE"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033336,
|
||||
"duration_us": 1316,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033305,
|
||||
"duration_us": 1459,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033300,
|
||||
"duration_us": 1767,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033290,
|
||||
"duration_us": 1180,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033283,
|
||||
"duration_us": 1421,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033260,
|
||||
"duration_us": 1291,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033260,
|
||||
"duration_us": 1551,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033260,
|
||||
"duration_us": 1083,
|
||||
"command": "ZRANGE"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033255,
|
||||
"duration_us": 1039,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793033254,
|
||||
"duration_us": 1215,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793033347,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
"redis_cpu_seconds_since_start": {
|
||||
"used_cpu_sys": 23.737706,
|
||||
"used_cpu_user": 35.526214
|
||||
},
|
||||
"redis_memory": {
|
||||
"used_memory": 2415952,
|
||||
"used_memory_peak": 2579896,
|
||||
"used_memory_rss": 8777728
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-59675.log",
|
||||
"samples": 1112,
|
||||
"observed_wall_ms": 111099,
|
||||
"cpu_seconds": 203.14,
|
||||
"cpu_core_equivalents": 1.828459302063925,
|
||||
"heapUsedBytes_sampled_max": 181098896,
|
||||
"heapCommittedBytes_sampled_max": 211812352,
|
||||
"poolActive_sampled_max": 7,
|
||||
"poolIdle_sampled_max": 7,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8,
|
||||
"residentBytes_sampled_max": 662786048
|
||||
},
|
||||
{
|
||||
"node_log": "node-59676.log",
|
||||
"samples": 1145,
|
||||
"observed_wall_ms": 114399,
|
||||
"cpu_seconds": 200.44,
|
||||
"cpu_core_equivalents": 1.7521132177728826,
|
||||
"heapUsedBytes_sampled_max": 185167416,
|
||||
"heapCommittedBytes_sampled_max": 219152384,
|
||||
"poolActive_sampled_max": 7,
|
||||
"poolIdle_sampled_max": 7,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8,
|
||||
"residentBytes_sampled_max": 662188032
|
||||
},
|
||||
{
|
||||
"node_log": "node-59677.log",
|
||||
"samples": 1159,
|
||||
"observed_wall_ms": 115800,
|
||||
"cpu_seconds": 200.16,
|
||||
"cpu_core_equivalents": 1.728497409326425,
|
||||
"heapUsedBytes_sampled_max": 179378632,
|
||||
"heapCommittedBytes_sampled_max": 210763776,
|
||||
"poolActive_sampled_max": 6,
|
||||
"poolIdle_sampled_max": 7,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8,
|
||||
"residentBytes_sampled_max": 703479808
|
||||
}
|
||||
],
|
||||
"cleanup": true
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin:21-jre",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 1000,
|
||||
"workers": 8
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"current_round": 1000,
|
||||
"completed_rounds": 1000,
|
||||
"seconds": 250.81072249996942,
|
||||
"renewals": {
|
||||
"samples": 96000,
|
||||
"p50_ms": 6.353199947625399,
|
||||
"p95_ms": 15.330899972468615,
|
||||
"p99_ms": 17.760399961844087,
|
||||
"max_ms": 326.8311999272555
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 12000,
|
||||
"p50_ms": 13.105699908919632,
|
||||
"p95_ms": 14.261199976317585,
|
||||
"p99_ms": 16.519399941898882,
|
||||
"max_ms": 915.3468000004068
|
||||
},
|
||||
"renewal_throughput": 382.7587554595546,
|
||||
"node_pids": [
|
||||
64000,
|
||||
40240,
|
||||
115400
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-0a8b7956499d-node-64951",
|
||||
"qqq-capacity-0a8b7956499d-node-64952",
|
||||
"qqq-capacity-0a8b7956499d-node-64953"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793030517,
|
||||
"duration_us": 1117,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793030517,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-64951.log",
|
||||
"samples": 2580,
|
||||
"observed_wall_ms": 257899,
|
||||
"cpu_seconds": 165.55,
|
||||
"cpu_core_equivalents": 0.6419179601316795,
|
||||
"heapUsedBytes_sampled_max": 166667368,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-64952.log",
|
||||
"samples": 2569,
|
||||
"observed_wall_ms": 256799,
|
||||
"cpu_seconds": 165.85,
|
||||
"cpu_core_equivalents": 0.6458358482704372,
|
||||
"heapUsedBytes_sampled_max": 169216944,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-64953.log",
|
||||
"samples": 2608,
|
||||
"observed_wall_ms": 260700,
|
||||
"cpu_seconds": 164.72,
|
||||
"cpu_core_equivalents": 0.631837360951285,
|
||||
"heapUsedBytes_sampled_max": 166319008,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 100,
|
||||
"workers": 8,
|
||||
"crash_round": 50,
|
||||
"churn_every": 20
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 100,
|
||||
"completed_rounds": 100,
|
||||
"churn_cycles": 5,
|
||||
"departed_leases_rejected": 40,
|
||||
"crash": {
|
||||
"round": 50,
|
||||
"http_port": 52824,
|
||||
"surviving_nodes": 2,
|
||||
"process_exit": 137
|
||||
},
|
||||
"seconds": 28.090077199973166,
|
||||
"renewals": {
|
||||
"samples": 9600,
|
||||
"p50_ms": 7.532799965701997,
|
||||
"p95_ms": 17.53149996511638,
|
||||
"p99_ms": 29.359900043345988,
|
||||
"max_ms": 88.13189994543791
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 1092,
|
||||
"p50_ms": 13.582199928350747,
|
||||
"p95_ms": 15.386099927127361,
|
||||
"p99_ms": 18.502700026147068,
|
||||
"max_ms": 22.144700051285326
|
||||
},
|
||||
"renewal_throughput": 341.75769371004685,
|
||||
"node_pids": [
|
||||
22844,
|
||||
66652,
|
||||
61920
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-bb9ba87919d2-node-52824",
|
||||
"qqq-capacity-bb9ba87919d2-node-52825",
|
||||
"qqq-capacity-bb9ba87919d2-node-52827"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793031681,
|
||||
"duration_us": 1206,
|
||||
"command": "EVALSHA"
|
||||
},
|
||||
{
|
||||
"unix_time": 1793031681,
|
||||
"duration_us": 1147,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793031681,
|
||||
1,
|
||||
1
|
||||
]
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-52824.log",
|
||||
"samples": 212,
|
||||
"observed_wall_ms": 21099,
|
||||
"cpu_seconds": 29.48,
|
||||
"cpu_core_equivalents": 1.3972226171856486,
|
||||
"heapUsedBytes_sampled_max": 163641024,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 3,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-52825.log",
|
||||
"samples": 354,
|
||||
"observed_wall_ms": 35299,
|
||||
"cpu_seconds": 43.09,
|
||||
"cpu_core_equivalents": 1.22071446783195,
|
||||
"heapUsedBytes_sampled_max": 168178592,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-52827.log",
|
||||
"samples": 340,
|
||||
"observed_wall_ms": 33900,
|
||||
"cpu_seconds": 42.42,
|
||||
"cpu_core_equivalents": 1.2513274336283187,
|
||||
"heapUsedBytes_sampled_max": 166291160,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 100,
|
||||
"workers": 8,
|
||||
"crash_round": 50
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 100,
|
||||
"completed_rounds": 100,
|
||||
"crash": {
|
||||
"round": 50,
|
||||
"http_port": 54039,
|
||||
"surviving_nodes": 2,
|
||||
"process_exit": 137
|
||||
},
|
||||
"seconds": 25.147547999978997,
|
||||
"renewals": {
|
||||
"samples": 9600,
|
||||
"p50_ms": 7.125200005248189,
|
||||
"p95_ms": 17.407900071702898,
|
||||
"p99_ms": 27.217599912546575,
|
||||
"max_ms": 74.53640003222972
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 996,
|
||||
"p50_ms": 13.538200058974326,
|
||||
"p95_ms": 15.59239998459816,
|
||||
"p99_ms": 20.58999997097999,
|
||||
"max_ms": 315.0679999962449
|
||||
},
|
||||
"renewal_throughput": 381.7469599822622,
|
||||
"node_pids": [
|
||||
85832,
|
||||
90280,
|
||||
67764
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-e599dca8e163-node-54039",
|
||||
"qqq-capacity-e599dca8e163-node-54040",
|
||||
"qqq-capacity-e599dca8e163-node-54041"
|
||||
],
|
||||
"redis_slow_commands": [],
|
||||
"redis_latency_events": [],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-54039.log",
|
||||
"samples": 198,
|
||||
"observed_wall_ms": 19699,
|
||||
"cpu_seconds": 28.58,
|
||||
"cpu_core_equivalents": 1.4508350677699375,
|
||||
"heapUsedBytes_sampled_max": 163478640,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 3,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-54040.log",
|
||||
"samples": 300,
|
||||
"observed_wall_ms": 29900,
|
||||
"cpu_seconds": 41.85,
|
||||
"cpu_core_equivalents": 1.399665551839465,
|
||||
"heapUsedBytes_sampled_max": 165177008,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-54041.log",
|
||||
"samples": 331,
|
||||
"observed_wall_ms": 33000,
|
||||
"cpu_seconds": 41.52,
|
||||
"cpu_core_equivalents": 1.2581818181818183,
|
||||
"heapUsedBytes_sampled_max": 168546936,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"status": "PASS",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"node_image": "eclipse-temurin@sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 30,
|
||||
"workers": 8,
|
||||
"crash_round": 0,
|
||||
"churn_every": 0,
|
||||
"pause_redis": true
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"node_image_id": "sha256:227118764cccd3c3294617061aaa93a08309c63a395eefa83e6b35032992ea99",
|
||||
"current_round": 30,
|
||||
"completed_rounds": 30,
|
||||
"redis_stall": {
|
||||
"rejected_renewals": 96,
|
||||
"failed_snapshot_nodes": 3,
|
||||
"response_times": {
|
||||
"samples": 96,
|
||||
"p50_ms": 1013.8123999349773,
|
||||
"p95_ms": 4014.4751999760047,
|
||||
"p99_ms": 4015.4822999611497,
|
||||
"max_ms": 4015.4822999611497
|
||||
},
|
||||
"same_leases_recovered": true
|
||||
},
|
||||
"seconds": 20.33888840000145,
|
||||
"renewals": {
|
||||
"samples": 2880,
|
||||
"p50_ms": 8.835799992084503,
|
||||
"p95_ms": 23.678299970924854,
|
||||
"p99_ms": 35.42319999542087,
|
||||
"max_ms": 102.84149996004999
|
||||
},
|
||||
"snapshots": {
|
||||
"samples": 372,
|
||||
"p50_ms": 13.98350007366389,
|
||||
"p95_ms": 16.946100047789514,
|
||||
"p99_ms": 20.020700059831142,
|
||||
"max_ms": 20.943500101566315
|
||||
},
|
||||
"renewal_throughput": 141.6006589622565,
|
||||
"node_pids": [
|
||||
88080,
|
||||
78036,
|
||||
121352
|
||||
],
|
||||
"pid_kind": "docker-client",
|
||||
"node_containers": [
|
||||
"qqq-capacity-68ad627e7be5-node-58312",
|
||||
"qqq-capacity-68ad627e7be5-node-58314",
|
||||
"qqq-capacity-68ad627e7be5-node-58315"
|
||||
],
|
||||
"redis_slow_commands": [
|
||||
{
|
||||
"unix_time": 1793032016,
|
||||
"duration_us": 2198,
|
||||
"command": "EVALSHA"
|
||||
}
|
||||
],
|
||||
"redis_latency_events": [
|
||||
[
|
||||
"command",
|
||||
1793032016,
|
||||
2,
|
||||
2
|
||||
]
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-58312.log",
|
||||
"samples": 280,
|
||||
"observed_wall_ms": 27899,
|
||||
"cpu_seconds": 23.96,
|
||||
"cpu_core_equivalents": 0.8588121438044375,
|
||||
"heapUsedBytes_sampled_max": 164221400,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 6,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 15,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-58314.log",
|
||||
"samples": 289,
|
||||
"observed_wall_ms": 28800,
|
||||
"cpu_seconds": 23.2,
|
||||
"cpu_core_equivalents": 0.8055555555555556,
|
||||
"heapUsedBytes_sampled_max": 162849016,
|
||||
"heapCommittedBytes_sampled_max": 192937984,
|
||||
"poolActive_sampled_max": 4,
|
||||
"poolIdle_sampled_max": 3,
|
||||
"poolWaiting_sampled_max": 14,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-58315.log",
|
||||
"samples": 272,
|
||||
"observed_wall_ms": 27100,
|
||||
"cpu_seconds": 23.85,
|
||||
"cpu_core_equivalents": 0.8800738007380073,
|
||||
"heapUsedBytes_sampled_max": 165779696,
|
||||
"heapCommittedBytes_sampled_max": 188743680,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 14,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"status": "FAIL",
|
||||
"parameters": {
|
||||
"java": "java (Java 21)",
|
||||
"nodes": 3,
|
||||
"tenants": 2,
|
||||
"users": 12,
|
||||
"tabs": 4,
|
||||
"rounds": 1000,
|
||||
"workers": 8
|
||||
},
|
||||
"scope": "loopback HTTP, mock identities, memory records",
|
||||
"current_round": 480,
|
||||
"completed_rounds": 479,
|
||||
"error": "HTTP 503 node=56028 user=load-t0-u5 path=/presence/leases/renew: {\"error\":\"Presence is temporarily unavailable. Please retry.\",\"code\":\"ERROR\",\"message\":\"Presence is temporarily unavailable. Please retry.\",\"traceId\":\"cea30b82-e82b-49ec-af8a-64d8b6bd5af6\"}",
|
||||
"redis_slow_commands": [],
|
||||
"redis_latency_events": [],
|
||||
"resources": [
|
||||
{
|
||||
"node_log": "node-56027.log",
|
||||
"samples": 1067,
|
||||
"observed_wall_ms": 106600,
|
||||
"cpu_seconds": 94.328125,
|
||||
"cpu_core_equivalents": 0.8848792213883677,
|
||||
"heapUsedBytes_sampled_max": 160559696,
|
||||
"heapCommittedBytes_sampled_max": 178257920,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 5,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-56028.log",
|
||||
"samples": 1066,
|
||||
"observed_wall_ms": 106501,
|
||||
"cpu_seconds": 95.8125,
|
||||
"cpu_core_equivalents": 0.8996394400052582,
|
||||
"heapUsedBytes_sampled_max": 160251104,
|
||||
"heapCommittedBytes_sampled_max": 178257920,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
},
|
||||
{
|
||||
"node_log": "node-56029.log",
|
||||
"samples": 1066,
|
||||
"observed_wall_ms": 106500,
|
||||
"cpu_seconds": 95.25,
|
||||
"cpu_core_equivalents": 0.8943661971830986,
|
||||
"heapUsedBytes_sampled_max": 157995216,
|
||||
"heapCommittedBytes_sampled_max": 175112192,
|
||||
"poolActive_sampled_max": 5,
|
||||
"poolIdle_sampled_max": 4,
|
||||
"poolWaiting_sampled_max": 0,
|
||||
"poolMaximum_sampled_max": 8
|
||||
}
|
||||
],
|
||||
"cleanup": true,
|
||||
"receipt_note": "Java executable path normalized; measurements unchanged. Disposable fixture identities only."
|
||||
}
|
||||
@@ -18,16 +18,25 @@
|
||||
<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>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<!-- 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>1.2.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<!-- qbit-email from Maven Central -->
|
||||
<qbit-email.version>0.1.0-RC.11</qbit-email.version>
|
||||
<!-- Core RC.22 (QBitMetaDataProducer, VirtualFieldValueProviderInterface, QServiceUnavailableException); BLOCKED until it resolves from Central/the registry in CI. -->
|
||||
<qqq.version>4.1.0-RC.26</qqq.version>
|
||||
<!-- qbit-redis first release candidate of release/0.1 (producer run 5119 did not publish it yet): BLOCKED, verify the published artifact before pushing. -->
|
||||
<qbit-redis.version>0.2.0-RC.1</qbit-redis.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -66,16 +75,55 @@
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-redis</artifactId>
|
||||
<version>${qbit-redis.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-object-storage</artifactId>
|
||||
<version>0.1.0-RC.2</version>
|
||||
<version>0.1.0-RC.10</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
<!-- optional: email through qbit-email when it is produced in the host instance (EmailCollaborationNotifier) -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-email</artifactId>
|
||||
<version>${qbit-email.version}</version>
|
||||
<optional>true</optional>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>*</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
@@ -87,10 +135,88 @@
|
||||
<version>3.27.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- upload journal and attachment transaction suites: real JDBC (H2, MySQL and PostgreSQL 17 containers) -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.4.240</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-mysql</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.8</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- native PostgreSQL module (vendor/type "postgres"), version managed by the qqq BOM -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-postgres</artifactId>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
</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>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-resources-plugin</artifactId><version>3.3.1</version>
|
||||
<executions><execution><id>package-license-notices</id><phase>process-resources</phase><goals><goal>copy-resources</goal></goals>
|
||||
<configuration><outputDirectory>${project.build.outputDirectory}/META-INF</outputDirectory><resources><resource>
|
||||
<directory>${project.basedir}</directory><filtering>false</filtering><includes><include>LICENSE</include><include>NOTICE</include></includes>
|
||||
</resource></resources></configuration>
|
||||
</execution></executions>
|
||||
</plugin>
|
||||
<!-- 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>
|
||||
@@ -169,30 +295,4 @@
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<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.5 ahead of the parent's 4.0.0 BOM. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.snapshot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentCleanup;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordReferenceCleanupInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
|
||||
/*******************************************************************************
|
||||
** Core invokes cleanup once the parent deletion commits.
|
||||
*******************************************************************************/
|
||||
public class CollaborationCleanup implements RecordReferenceCleanupInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Existing attachment cleanup includes tag counts, shares, tasks and assignments.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onParentsDeleted(String tableName, List<Serializable> primaryKeys) throws QException
|
||||
{
|
||||
new AttachmentCleanup().onParentsDeleted(tableName, primaryKeys);
|
||||
for(String table : List.of(CollaborationTables.FIELD_VALUE, CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION, CollaborationTables.COMMENT,
|
||||
CollaborationTables.FOLLOW_DIGEST, CollaborationTables.FOLLOW, CollaborationTables.ACTIVITY))
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
var rows = CollaborationRecords.cleanupQuery(table, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName)
|
||||
.withCriteria("recordId", QCriteriaOperator.IN, primaryKeys.stream().map(Object::toString).toList()).withLimit(200));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(var row : rows)
|
||||
{
|
||||
CollaborationRecords.cleanupDelete(table, row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,14 @@
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
@@ -36,16 +42,87 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
public static final String TABLE_NAME_PREFIX = "collaboration_";
|
||||
public static final String PERMISSION_BASE_NAME = "collaboration";
|
||||
|
||||
private List<RecordFeature> defaultFeatures = List.of(RecordFeature.FAVORITES, RecordFeature.RECENTS, RecordFeature.TIMELINE);
|
||||
private Map<String, List<RecordFeature>> tableFeatures = new LinkedHashMap<>();
|
||||
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";
|
||||
private Integer tagLookupCacheSeconds = 30;
|
||||
private List<String> tagActionTables = List.of();
|
||||
private List<String> followActionTables = List.of();
|
||||
private List<String> followChangeTables = List.of();
|
||||
private String frontendBaseUrl;
|
||||
private QCodeReference permissionHolderDirectory;
|
||||
private List<String> assignActionTables = List.of();
|
||||
private String backendName;
|
||||
private String schedulerName;
|
||||
private Boolean managedIndexes = true;
|
||||
private String appName = "collaboration";
|
||||
private String parentAppName;
|
||||
private Boolean adminApp = true;
|
||||
private Boolean uiApi = true;
|
||||
private QCodeReference attachmentStorage = new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentStorage.class);
|
||||
private QCodeReference attachmentLifecycle = new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentLifecyclePolicy.class);
|
||||
private Integer recentsPerUser = 200;
|
||||
private String attachmentsBucketName = "collaboration";
|
||||
private Long defaultMaxBytes = 100L * 1024 * 1024;
|
||||
private List<String> blockedExtensions = List.of();
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the versioned collaboration UI API is registered.
|
||||
*******************************************************************************/
|
||||
public Boolean getUiApi()
|
||||
{
|
||||
return (uiApi);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Enable or disable the versioned UI API (enabled by default).
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withUiApi(Boolean uiApi)
|
||||
{
|
||||
this.uiApi = uiApi;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Storage boundary used by attachment operations, including HTTP uploads.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getAttachmentStorage()
|
||||
{
|
||||
return (attachmentStorage);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure a storage adapter; the default uses qbit-object-storage.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withAttachmentStorage(QCodeReference attachmentStorage)
|
||||
{
|
||||
this.attachmentStorage = attachmentStorage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted host policy for retained attachments and access auditing.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getAttachmentLifecycle()
|
||||
{
|
||||
return (attachmentLifecycle);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure lifecycle enforcement without coupling to a consuming qbit.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withAttachmentLifecycle(QCodeReference attachmentLifecycle)
|
||||
{
|
||||
this.attachmentLifecycle = java.util.Objects.requireNonNull(attachmentLifecycle, "attachmentLifecycle");
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -240,10 +317,24 @@ 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);
|
||||
}
|
||||
assertCondition(List.of("FREE", "APPROVED_ONLY").contains(defaultTagMode == null ? "" : defaultTagMode), "defaultTagMode must be FREE or APPROVED_ONLY", errors);
|
||||
assertCondition(tagLookupCacheSeconds != null && tagLookupCacheSeconds >= 0, "tagLookupCacheSeconds must be 0 (off) or more", errors);
|
||||
for(List<String> tables : List.of(followActionTables, followChangeTables, tagActionTables, assignActionTables))
|
||||
{
|
||||
for(String table : tables)
|
||||
{
|
||||
assertCondition(table != null && !table.startsWith(TABLE_NAME_PREFIX) && instance.getTable(table) != null
|
||||
&& instance.getTable(table).getPrimaryKeyField() != null, "Follow configuration needs a host table with a primary key: " + table, errors);
|
||||
}
|
||||
}
|
||||
assertCondition(StringUtils.hasContent(attachmentsBucketName), "attachmentsBucketName is required", errors);
|
||||
assertCondition(defaultMaxBytes != null && defaultMaxBytes > 0, "defaultMaxBytes must be positive", errors);
|
||||
assertCondition(blockedExtensions != null && blockedExtensions.stream().allMatch(value -> value != null && !value.isBlank()), "blockedExtensions must contain nonblank extensions", errors);
|
||||
@@ -356,4 +447,294 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
setBlockedExtensions(blockedExtensions);
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get notifier. Null (the default) means: email through qbit-email when it is
|
||||
** produced in the instance, otherwise no delivery transport.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getNotifier()
|
||||
{
|
||||
return (notifier);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the user id to email address lookup used by the email notifier.
|
||||
*******************************************************************************/
|
||||
public Function<String, String> getRecipientEmailResolver()
|
||||
{
|
||||
return (recipientEmailResolver);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure how the email notifier finds a recipient address from a user id.
|
||||
** Null (the default) uses the email stored in the collaboration people directory.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withRecipientEmailResolver(Function<String, String> recipientEmailResolver)
|
||||
{
|
||||
this.recipientEmailResolver = recipientEmailResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure notifier.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withNotifier(QCodeReference notifier)
|
||||
{
|
||||
this.notifier = notifier;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get userSessionProvider.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getUserSessionProvider()
|
||||
{
|
||||
return (userSessionProvider);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure userSessionProvider.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withUserSessionProvider(QCodeReference userSessionProvider)
|
||||
{
|
||||
this.userSessionProvider = userSessionProvider;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get followActionTables.
|
||||
*******************************************************************************/
|
||||
public List<String> getFollowActionTables()
|
||||
{
|
||||
return (followActionTables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure followActionTables.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withFollowActionTables(List<String> followActionTables)
|
||||
{
|
||||
this.followActionTables = followActionTables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get followChangeTables.
|
||||
*******************************************************************************/
|
||||
public List<String> getFollowChangeTables()
|
||||
{
|
||||
return (followChangeTables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure followChangeTables.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withFollowChangeTables(List<String> followChangeTables)
|
||||
{
|
||||
this.followChangeTables = followChangeTables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Default vocabulary policy when no persisted override supplies a mode.
|
||||
*******************************************************************************/
|
||||
public String getDefaultTagMode()
|
||||
{
|
||||
return (defaultTagMode);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the default tag policy.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withDefaultTagMode(String mode)
|
||||
{
|
||||
this.defaultTagMode = mode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** How long a node remembers whether a table has any tag assignment.
|
||||
*******************************************************************************/
|
||||
public Integer getTagLookupCacheSeconds()
|
||||
{
|
||||
return (tagLookupCacheSeconds);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reads of a table without any tag assignment skip the assignment lookup while
|
||||
** this node remembers that (default 30 seconds; 0 looks every time). A node
|
||||
** that writes an assignment forgets at once and tells the others over the
|
||||
** ClusterBus; without a cluster transport another node shows a table's first
|
||||
** tag after at most this long. Tables that have assignments are always read.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withTagLookupCacheSeconds(Integer tagLookupCacheSeconds)
|
||||
{
|
||||
this.tagLookupCacheSeconds = tagLookupCacheSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host tables receiving Add tags and Remove tags actions.
|
||||
*******************************************************************************/
|
||||
public List<String> getTagActionTables()
|
||||
{
|
||||
return (tagActionTables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure host tag actions.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withTagActionTables(List<String> tables)
|
||||
{
|
||||
this.tagActionTables = tables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host configuration for frontendBaseUrl.
|
||||
*******************************************************************************/
|
||||
public String getFrontendBaseUrl()
|
||||
{
|
||||
return (frontendBaseUrl);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure frontendBaseUrl.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withFrontendBaseUrl(String value)
|
||||
{
|
||||
this.frontendBaseUrl = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host configuration for permissionHolderDirectory.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getPermissionHolderDirectory()
|
||||
{
|
||||
return (permissionHolderDirectory);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure permissionHolderDirectory.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withPermissionHolderDirectory(QCodeReference value)
|
||||
{
|
||||
this.permissionHolderDirectory = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host configuration for assignActionTables.
|
||||
*******************************************************************************/
|
||||
public List<String> getAssignActionTables()
|
||||
{
|
||||
return (assignActionTables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure assignActionTables.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withAssignActionTables(List<String> value)
|
||||
{
|
||||
this.assignActionTables = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace the instance feature defaults.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withDefaultFeatures(RecordFeature... features)
|
||||
{
|
||||
defaultFeatures = List.of(features);
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare the complete enabled feature set for a host table.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withTableFeatures(String table, RecordFeature... features)
|
||||
{
|
||||
tableFeatures.put(table, List.of(features));
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply code declarations before core enrichment and permission registration.
|
||||
*******************************************************************************/
|
||||
public void applyFeatures(QInstance instance)
|
||||
{
|
||||
RecordFeaturesMetaData defaults = RecordFeaturesMetaData.ofOrWithNew(instance);
|
||||
for(RecordFeature feature : RecordFeature.values())
|
||||
{
|
||||
defaults.getEnabled().putIfAbsent(feature, defaultFeatures.contains(feature));
|
||||
}
|
||||
tableFeatures.forEach((name, features) ->
|
||||
{
|
||||
RecordFeaturesMetaData metadata = RecordFeaturesMetaData.ofOrWithNew(instance.getTable(name));
|
||||
for(RecordFeature feature : RecordFeature.values())
|
||||
{
|
||||
metadata.withEnabled(feature, features.contains(feature));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parent security key types copied into extension tables; none by default.
|
||||
*******************************************************************************/
|
||||
public List<String> getMirroredSecurityKeyTypes()
|
||||
{
|
||||
return (mirroredSecurityKeyTypes);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure mirrored parent locks.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withMirroredSecurityKeyTypes(List<String> types)
|
||||
{
|
||||
mirroredSecurityKeyTypes = List.copyOf(types);
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the index job executes DDL or only records migration statements.
|
||||
*******************************************************************************/
|
||||
public Boolean getManagedIndexes()
|
||||
{
|
||||
return (managedIndexes);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure host-managed migrations instead of runtime index creation.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withManagedIndexes(Boolean managedIndexes)
|
||||
{
|
||||
this.managedIndexes = managedIndexes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bean configuration support.
|
||||
*******************************************************************************/
|
||||
public void setManagedIndexes(Boolean managedIndexes)
|
||||
{
|
||||
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,12 +155,43 @@ 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();
|
||||
CollaborationSecurity.buildSecurityKeyTypes().forEach(output::add);
|
||||
output.add(TrimRecentsProcess.metadata(qBitConfig));
|
||||
output.add(com.kingsrook.qbits.collaboration.customfields.BuildFieldIndexes.metadata(qBitConfig));
|
||||
com.kingsrook.qbits.collaboration.customfields.FieldProcesses.metadata().forEach(output::add);
|
||||
output.add(com.kingsrook.qbits.collaboration.tasks.SendTaskRemindersProcess.metadata(qBitConfig));
|
||||
WorkProcesses.management().forEach(output::add);
|
||||
for(String table : qBitConfig.getAssignActionTables())
|
||||
{
|
||||
WorkProcesses.assignments(table).forEach(output::add);
|
||||
}
|
||||
output.add(com.kingsrook.qbits.collaboration.follow.SendFollowDigestsProcess.metadata(qBitConfig));
|
||||
for(String table : qBitConfig.getFollowActionTables())
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.follow.FollowProcesses.processesFor(table).forEach(output::add);
|
||||
}
|
||||
if(!qBitConfig.getFollowChangeTables().isEmpty())
|
||||
{
|
||||
instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.collaboration.follow.FollowChangeBatcher.class));
|
||||
}
|
||||
com.kingsrook.qbits.collaboration.tags.TagProcesses.management().forEach(output::add);
|
||||
for(String table : qBitConfig.getTagActionTables())
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.tags.TagProcesses.processesFor(table).forEach(output::add);
|
||||
}
|
||||
output.add(DeleteAttachmentsProcess.metadata());
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.RetryAttachmentCleanupProcess.metadata(qBitConfig));
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.ResolveAttachmentUploadProcess.metadata());
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.RecoverRetainedUploadProcess.metadata());
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.AdoptRetainedAttachmentProcess.metadata());
|
||||
output.add(FeatureSettingsDefaultsProcess.process());
|
||||
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
|
||||
{
|
||||
output.add(source);
|
||||
@@ -140,15 +203,24 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
}
|
||||
if(Boolean.TRUE.equals(qBitConfig.getAdminApp()))
|
||||
{
|
||||
output.add(CollaborationHome.widget());
|
||||
CollaborationHome.widgets().forEach(output::add);
|
||||
output.add(CollaborationHome.app(qBitConfig));
|
||||
}
|
||||
output.addSelfToInstance(instance);
|
||||
CoreCollaboration.install(instance, qBitConfig);
|
||||
instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(CollaborationRealtime.class));
|
||||
if(Boolean.TRUE.equals(qBitConfig.getUiApi()))
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.api.CollaborationApi.register(instance);
|
||||
}
|
||||
com.kingsrook.qbits.collaboration.model.ConversationChildren.install(instance);
|
||||
if(Boolean.TRUE.equals(qBitConfig.getAdminApp()) && qBitConfig.getParentAppName() != null)
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.Map;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeEvent;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
|
||||
|
||||
/*******************************************************************************
|
||||
** Identifier-only hints, published after successful commit. A tag assignment
|
||||
** also clears what this node remembers about untagged tables (TaggedTables).
|
||||
*******************************************************************************/
|
||||
public class CollaborationRealtime implements RecordChangeListenerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(CollaborationRealtime.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** Only records with client invalidation contracts.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean appliesTo(String table, RecordChangeType type)
|
||||
{
|
||||
return (java.util.List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.RECORD_TAG, CollaborationTables.TASK).contains(table));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture immutable payloads while the write's session and transaction are active.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onRecordsChanged(RecordChangeEvent change) throws QException
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
java.util.Set<String> newlyTaggedTables = new java.util.HashSet<>();
|
||||
for(int index = 0; index < change.getRecords().size(); index++)
|
||||
{
|
||||
var row = new com.kingsrook.qqq.backend.core.model.data.QRecord(change.getRecords().get(index));
|
||||
if(change.getOldRecords() != null && index < change.getOldRecords().size() && change.getOldRecords().get(index) != null)
|
||||
{
|
||||
var complete = new com.kingsrook.qqq.backend.core.model.data.QRecord(change.getOldRecords().get(index));
|
||||
complete.getValues().putAll(row.getValues());
|
||||
row = complete;
|
||||
}
|
||||
String table = row.getValueString("tableName");
|
||||
String id = row.getValueString("recordId");
|
||||
if(table == null || id == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(CollaborationTables.RECORD_TAG.equals(change.getTableName()) && change.getType() != RecordChangeType.DELETE && newlyTaggedTables.add(table))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// the Tags field skips tables known to have no assignment: forget it //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
com.kingsrook.qbits.collaboration.tags.TaggedTables.assignmentsChanged(table, change.getTransaction());
|
||||
}
|
||||
String event = switch(change.getTableName())
|
||||
{
|
||||
case CollaborationTables.COMMENT -> change.getType() == RecordChangeType.DELETE || row.getValue("deletedAt") != null
|
||||
? "comment.deleted" : change.getType() == RecordChangeType.INSERT ? "comment.added" : "comment.updated";
|
||||
case CollaborationTables.ATTACHMENT -> "attachment.changed";
|
||||
case CollaborationTables.RECORD_TAG -> "tags.changed";
|
||||
default -> "task.changed";
|
||||
};
|
||||
var message = new RealtimeMessage().withChannel("record:" + table + ":" + id).withEvent(event)
|
||||
.withData(Map.of("table", table, "pk", id, "id", row.getValue("id")));
|
||||
Runnable publish = () ->
|
||||
{
|
||||
try
|
||||
{
|
||||
RealtimeBus.get(instance).publish(message);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not publish collaboration invalidation", e);
|
||||
}
|
||||
};
|
||||
if(change.getTransaction() == null)
|
||||
{
|
||||
publish.run();
|
||||
}
|
||||
else
|
||||
{
|
||||
change.getTransaction().addAfterCommitCallback(publish);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
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.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal persistence for trusted services. Callers must authorize the parent
|
||||
** before reading or writing. Never expose these methods as API operations.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationRecords
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static persistence boundary.
|
||||
*******************************************************************************/
|
||||
private CollaborationRecords()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the installed qbit configuration.
|
||||
*******************************************************************************/
|
||||
public static CollaborationQBitConfig config()
|
||||
{
|
||||
return ((CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
|
||||
.filter(bit -> bit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Restrict a service query to one canonical parent.
|
||||
*******************************************************************************/
|
||||
public static QQueryFilter parent(String table, String id)
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
|
||||
.withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Raw reads retain transaction visibility but omit presentation customizers.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (query(table, filter, QContext.getQBackendTransaction()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider reads participate in the originating query's explicit transaction.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String table, QQueryFilter filter, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(transaction);
|
||||
input.setTableMetaData(serviceMetadata(table));
|
||||
input.withPermissionBypass("Trusted collaboration service performs parent/ownership authorization.");
|
||||
return (new QueryAction().execute(input).getRecords().stream().map(QRecord::new).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist generated keys and propagate record validation errors.
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String table, QRecord row) throws QException
|
||||
{
|
||||
return (insert(serviceMetadata(table), row));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Activity belongs to the authorized source operation; timeline controls its
|
||||
** presentation. Preserve native parent checks and mirrored security keys.
|
||||
*******************************************************************************/
|
||||
public static QRecord insertActivity(QRecord row, RecordFeature sourceFeature) throws QException
|
||||
{
|
||||
QTableMetaData metadata = serviceMetadata(CollaborationTables.ACTIVITY);
|
||||
RecordReferenceTableMetaData original = RecordReferenceTableMetaData.of(metadata);
|
||||
if(original == null || sourceFeature == null)
|
||||
{
|
||||
throw (new QException("Activity source authorization metadata is required."));
|
||||
}
|
||||
RecordReferenceTableMetaData reference = (RecordReferenceTableMetaData) original.clone();
|
||||
reference.withFeature(sourceFeature);
|
||||
metadata.withSupplementalMetaData(reference);
|
||||
return (insert(metadata, row));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A private metadata copy retains the same transaction and validation path.
|
||||
*******************************************************************************/
|
||||
private static QRecord insert(QTableMetaData metadata, QRecord row) throws QException
|
||||
{
|
||||
InsertInput input = new InsertInput(metadata.getName()).withRecords(List.of(row)).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(metadata);
|
||||
input.withPermissionBypass("Trusted collaboration service authorized this write.");
|
||||
List<QRecord> result = new InsertAction().execute(input).getRecords();
|
||||
check(result);
|
||||
return (result.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only supplied fields are changed; preserve auditing.
|
||||
*******************************************************************************/
|
||||
public static void update(String table, QRecord row) throws QException
|
||||
{
|
||||
UpdateInput input = new UpdateInput(table).withRecords(List.of(row)).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(serviceMetadata(table));
|
||||
input.withPermissionBypass("Trusted collaboration service authorized this write.");
|
||||
check(new UpdateAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a service-selected row.
|
||||
*******************************************************************************/
|
||||
public static void delete(String table, Serializable id) throws QException
|
||||
{
|
||||
DeleteInput input = new DeleteInput(table).withPrimaryKeys(List.of(id)).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(serviceMetadata(table));
|
||||
input.withPermissionBypass("Trusted collaboration service authorized this deletion.");
|
||||
check(new DeleteAction().execute(input).getRecordsWithErrors());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted after-delete reads must see children whose parent no longer exists.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> cleanupQuery(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true);
|
||||
input.setTableMetaData(cleanupMetadata(table));
|
||||
input.withPermissionBypass("Core after-commit parent cleanup.");
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete only identifiers selected by a trusted cleanup provider.
|
||||
*******************************************************************************/
|
||||
public static void cleanupDelete(String table, Serializable id) throws QException
|
||||
{
|
||||
DeleteInput input = new DeleteInput(table).withPrimaryKeys(List.of(id));
|
||||
input.setTableMetaData(cleanupMetadata(table));
|
||||
input.withPermissionBypass("Core after-commit parent cleanup.");
|
||||
check(new DeleteAction().execute(input).getRecordsWithErrors());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Private metadata for derived cleanup, never installed on the instance.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData cleanupMetadata(String table)
|
||||
{
|
||||
QTableMetaData metadata = serviceMetadata(table);
|
||||
if(metadata.getSupplementalMetaData() != null)
|
||||
{
|
||||
metadata.getSupplementalMetaData().remove(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData.NAME);
|
||||
}
|
||||
metadata.setRecordSecurityLocks(null);
|
||||
return (metadata);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A verified share recipient may acknowledge a share without reading its parent.
|
||||
*******************************************************************************/
|
||||
public static void acknowledgeShare(Integer id, java.time.Instant openedAt) throws QException
|
||||
{
|
||||
QRecord row = WorkSupport.row(com.kingsrook.qbits.collaboration.model.CollaborationTables.SHARE, id);
|
||||
if(!PersonalRecords.currentUserId().equals(row.getValueString("toUserId")))
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QNotFoundException("Not found"));
|
||||
}
|
||||
UpdateInput input = new UpdateInput(com.kingsrook.qbits.collaboration.model.CollaborationTables.SHARE)
|
||||
.withRecords(List.of(new QRecord().withValue("id", id).withValue("openedAt", openedAt))).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(cleanupMetadata(com.kingsrook.qbits.collaboration.model.CollaborationTables.SHARE));
|
||||
input.withPermissionBypass("Verified share recipient acknowledges receipt; no parent data is changed.");
|
||||
check(new UpdateAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Access-request services enforce requester/approver authority explicitly.
|
||||
** Bypass only that owner lock on a private metadata clone, retaining the caller
|
||||
** for audit attribution. Standard actions always retain the installed lock.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData serviceMetadata(String table)
|
||||
{
|
||||
QTableMetaData metadata = QContext.getQInstance().getTable(table).clone();
|
||||
if(!table.equals(com.kingsrook.qbits.collaboration.model.CollaborationTables.FEATURE_SETTING))
|
||||
{
|
||||
metadata.withCustomizers(null);
|
||||
}
|
||||
if(table.equals(com.kingsrook.qbits.collaboration.model.CollaborationTables.ACCESS_REQUEST))
|
||||
{
|
||||
metadata.setRecordSecurityLocks(null);
|
||||
}
|
||||
return (metadata);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core actions can return validation errors instead of throwing.
|
||||
*******************************************************************************/
|
||||
private static void check(List<QRecord> rows) throws QException
|
||||
{
|
||||
if(rows != null)
|
||||
{
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
if(row.getErrors() != null && !row.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not save collaboration record: " + row.getErrors()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,19 +57,58 @@ public final class CollaborationSchema
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String type = field.getName().equals(table.getPrimaryKeyField()) ? "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY" : sqlType(field);
|
||||
columns.add(" " + field.getBackendName() + " " + type + (field.getName().equals("parentDeleted") ? " DEFAULT FALSE" : "") + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
|
||||
columns.add(" " + field.getBackendName() + " " + type + (field.getName().equals("parentDeleted") || field.getName().equals("retained") ? " DEFAULT FALSE" : "") + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
|
||||
}
|
||||
statements.add("CREATE TABLE IF NOT EXISTS " + table.getName() + " (\n" + String.join(",\n", columns) + "\n)");
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(field.getName().startsWith("lockKey_"))
|
||||
{
|
||||
statements.add("ALTER TABLE " + table.getName() + " ADD COLUMN IF NOT EXISTS " + field.getBackendName() + " " + sqlType(field));
|
||||
}
|
||||
}
|
||||
int index = 0;
|
||||
for(UniqueKey key : table.getUniqueKeys() == null ? List.<UniqueKey>of() : table.getUniqueKeys())
|
||||
{
|
||||
index++;
|
||||
List<String> names = key.getFieldNames().stream().map(name -> table.getField(name).getBackendName()).toList();
|
||||
statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_" + table.getName() + "_" + index + " ON " + table.getName() + " (" + String.join(", ", names) + ")"
|
||||
+ (key.getFieldNames().contains("recordId") ? " NULLS NOT DISTINCT" : ""));
|
||||
+ (key.getFieldNames().stream().anyMatch(name -> !Boolean.TRUE.equals(table.getField(name).getIsRequired())) ? " NULLS NOT DISTINCT" : ""));
|
||||
}
|
||||
}
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_record_tag_tag ON collaboration_record_tag (tag_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_parent ON collaboration_attachment (table_name, record_id)");
|
||||
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS retained BOOLEAN NOT NULL DEFAULT FALSE");
|
||||
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS intake_key VARCHAR(64)");
|
||||
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS intake_fingerprint VARCHAR(64)");
|
||||
statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_attachment_intake ON collaboration_attachment (intake_key) WHERE intake_key IS NOT NULL");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_parent ON collaboration_comment (table_name, record_id, last_activity_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_created ON collaboration_comment (table_name, record_id, create_date)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_thread ON collaboration_comment (thread_id, create_date, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_revision_comment ON collaboration_comment_revision (comment_id, edited_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_activity_parent ON collaboration_activity (table_name, record_id, at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_digest_due ON collaboration_follow_digest (due_at)");
|
||||
statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_access_request_open ON collaboration_access_request (table_name, record_id, requester_user_id) WHERE status = 'OPEN'");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_share_parent ON collaboration_share (table_name, record_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_share_sent ON collaboration_share (sent_at)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_access_request_requester ON collaboration_access_request (requester_user_id, status)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_task_parent ON collaboration_task (table_name, record_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_task_mine ON collaboration_task (assignee_user_id, status, due_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_task_reminder ON collaboration_task (remind_at) WHERE status = 'OPEN' AND reminded_at IS NULL");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_task_overdue ON collaboration_task (due_at) WHERE status = 'OPEN' AND overdue_notified_at IS NULL");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_assignment_owner ON collaboration_record_assignment (owner_user_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_assignment_assignee ON collaboration_record_assignment (assignee_user_id)");
|
||||
statements.add("ALTER TABLE collaboration_mention ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS resolved_by VARCHAR(255)");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS settlement_evidence VARCHAR(2000)");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ALTER COLUMN attachment_id DROP NOT NULL");
|
||||
statements.add("DROP INDEX IF EXISTS uq_collaboration_attachment_cleanup_1");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_storage_key ON collaboration_attachment USING HASH (storage_key)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_cleanup_due ON collaboration_attachment_cleanup (next_attempt_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_cleanup_pending ON collaboration_attachment_cleanup (next_attempt_at, id) WHERE resolved_at IS NULL");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_mention_unread ON collaboration_mention (mentioned_user_id) WHERE notified_at IS NOT NULL AND read_at IS NULL");
|
||||
return (String.join(";\n", statements) + ";\n");
|
||||
}
|
||||
|
||||
@@ -93,4 +132,16 @@ public final class CollaborationSchema
|
||||
default -> throw new IllegalArgumentException("Unsupported schema type: " + field.getType());
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-owned one-time PostgreSQL migration; this helper never executes SQL.
|
||||
*******************************************************************************/
|
||||
public static String customFieldsDdl(String tableName)
|
||||
{
|
||||
String table = java.util.Arrays.stream(tableName.split("\\.")).map(com.kingsrook.qbits.collaboration.customfields.CustomFieldStorage::identifier)
|
||||
.collect(java.util.stream.Collectors.joining("."));
|
||||
String index = "cf_gin_" + Integer.toUnsignedString(tableName.hashCode(), 16);
|
||||
return ("ALTER TABLE " + table + " ADD COLUMN IF NOT EXISTS qqq_custom_fields JSONB NOT NULL DEFAULT '{}';\n"
|
||||
+ "CREATE INDEX IF NOT EXISTS " + index + " ON " + table + " USING gin (qqq_custom_fields jsonb_path_ops);\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeatureSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeatureSummary;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
|
||||
|
||||
/*******************************************************************************
|
||||
** Caller-scoped values for the core record features endpoint.
|
||||
*******************************************************************************/
|
||||
public class CollaborationSummaryProvider implements RecordFeatureSummaryProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Core has already checked the parent; omit values for disabled features.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void contribute(RecordReference ref, QRecord parent, RecordFeatureSummary summary) throws QException
|
||||
{
|
||||
var features = RecordFeaturesResolver.resolve(QContext.getQInstance(), QContext.getQInstance().getTable(ref.tableName()));
|
||||
String id = parent.getValueString(QContext.getQInstance().getTable(ref.tableName()).getPrimaryKeyField());
|
||||
String user = PersonalRecords.currentUserId();
|
||||
if(features.isEnabled(RecordFeature.PRESENCE))
|
||||
{
|
||||
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());
|
||||
entry.put("displayName", viewer.displayName());
|
||||
entry.put("mode", viewer.mode());
|
||||
entry.put("fieldName", viewer.fieldName());
|
||||
return (entry);
|
||||
}).toList());
|
||||
}
|
||||
if(features.isEnabled(RecordFeature.FAVORITES))
|
||||
{
|
||||
summary.setIsFavorite(!CollaborationRecords.query(CollaborationTables.FAVORITE, new QQueryFilter()
|
||||
.withCriteria("targetType", QCriteriaOperator.EQUALS, "RECORD").withCriteria("targetName", QCriteriaOperator.EQUALS, ref.tableName())
|
||||
.withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("userId", QCriteriaOperator.EQUALS, user)).isEmpty());
|
||||
}
|
||||
if(features.isEnabled(RecordFeature.FOLLOW))
|
||||
{
|
||||
var rows = CollaborationRecords.query(CollaborationTables.FOLLOW, CollaborationRecords.parent(ref.tableName(), id));
|
||||
summary.setIsFollowing(rows.stream().anyMatch(row -> user.equals(row.getValueString("userId"))));
|
||||
summary.getCounts().put("followers", rows.size());
|
||||
}
|
||||
if(features.isEnabled(RecordFeature.COMMENTS))
|
||||
{
|
||||
summary.getCounts().put("openComments", CollaborationRecords.query(CollaborationTables.COMMENT, CollaborationRecords.parent(ref.tableName(), id)
|
||||
.withCriteria("deletedAt", QCriteriaOperator.IS_BLANK).withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK)).size());
|
||||
}
|
||||
if(features.isEnabled(RecordFeature.ATTACHMENTS))
|
||||
{
|
||||
summary.getCounts().put("attachments", CollaborationRecords.query(CollaborationTables.ATTACHMENT, CollaborationRecords.parent(ref.tableName(), id)).size());
|
||||
}
|
||||
if(features.isEnabled(RecordFeature.TAGS))
|
||||
{
|
||||
summary.getCounts().put("tags", CollaborationRecords.query(CollaborationTables.RECORD_TAG, CollaborationRecords.parent(ref.tableName(), id)).size());
|
||||
}
|
||||
if(features.isEnabled(RecordFeature.TASKS))
|
||||
{
|
||||
summary.getCounts().put("openTasks", CollaborationRecords.query(CollaborationTables.TASK, CollaborationRecords.parent(ref.tableName(), id)
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "OPEN")).size());
|
||||
}
|
||||
if(features.isEnabled(RecordFeature.ASSIGNMENT))
|
||||
{
|
||||
var rows = CollaborationRecords.query(CollaborationTables.RECORD_ASSIGNMENT, CollaborationRecords.parent(ref.tableName(), id));
|
||||
if(!rows.isEmpty())
|
||||
{
|
||||
summary.withOwner(rows.getFirst().getValueString("ownerUserId")).withAssignee(rows.getFirst().getValueString("assigneeUserId"));
|
||||
}
|
||||
if(features.getOwnership() != null)
|
||||
{
|
||||
var ownership = features.getOwnership();
|
||||
if(ownership.getOwnerFieldName() != null)
|
||||
{
|
||||
summary.setOwner(parent.getValueString(ownership.getOwnerFieldName()));
|
||||
}
|
||||
if(ownership.getAssigneeFieldName() != null)
|
||||
{
|
||||
summary.setAssignee(parent.getValueString(ownership.getAssigneeFieldName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.Map;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordReferenceSecurityHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceSecurityConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData;
|
||||
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.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Install the qbit implementations of core collaboration contracts.
|
||||
*******************************************************************************/
|
||||
public final class CoreCollaboration
|
||||
{
|
||||
private static final Map<String, RecordFeature> REFERENCES = Map.ofEntries(
|
||||
Map.entry(CollaborationTables.FIELD_VALUE, RecordFeature.CUSTOM_FIELDS),
|
||||
Map.entry(CollaborationTables.ATTACHMENT, RecordFeature.ATTACHMENTS),
|
||||
Map.entry(CollaborationTables.RECORD_TAG, RecordFeature.TAGS),
|
||||
Map.entry(CollaborationTables.COMMENT, RecordFeature.COMMENTS),
|
||||
Map.entry(CollaborationTables.COMMENT_REVISION, RecordFeature.COMMENTS),
|
||||
Map.entry(CollaborationTables.MENTION, RecordFeature.COMMENTS),
|
||||
Map.entry(CollaborationTables.FOLLOW, RecordFeature.FOLLOW),
|
||||
Map.entry(CollaborationTables.FOLLOW_DIGEST, RecordFeature.FOLLOW),
|
||||
Map.entry(CollaborationTables.ACTIVITY, RecordFeature.TIMELINE),
|
||||
Map.entry(CollaborationTables.SHARE, RecordFeature.SHARING),
|
||||
Map.entry(CollaborationTables.TASK, RecordFeature.TASKS),
|
||||
Map.entry(CollaborationTables.RECORD_ASSIGNMENT, RecordFeature.ASSIGNMENT));
|
||||
|
||||
/*******************************************************************************
|
||||
** Static installer.
|
||||
*******************************************************************************/
|
||||
private CoreCollaboration()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve other providers installed by the host.
|
||||
*******************************************************************************/
|
||||
public static void install(QInstance instance, CollaborationQBitConfig config)
|
||||
{
|
||||
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));
|
||||
features.getSummaryProviders().add(new QCodeReference(CollaborationSummaryProvider.class));
|
||||
var security = RecordReferenceSecurityConfig.ofOrWithNew(instance).withMirroredSecurityKeyTypes(config.getMirroredSecurityKeyTypes());
|
||||
security.getCleanupProviders().add(new QCodeReference(CollaborationCleanup.class));
|
||||
RecordReferenceSecurityHelper.register(instance);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Children already carry denormalized references in this qbit schema.
|
||||
*******************************************************************************/
|
||||
public static void reference(QTableMetaData table, CollaborationQBitConfig config)
|
||||
{
|
||||
RecordFeature feature = REFERENCES.get(table.getName());
|
||||
if(feature == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String author = switch(table.getName())
|
||||
{
|
||||
case CollaborationTables.ATTACHMENT -> "createdByUserId";
|
||||
case CollaborationTables.COMMENT -> "authorUserId";
|
||||
case CollaborationTables.SHARE -> "fromUserId";
|
||||
default -> null;
|
||||
};
|
||||
table.withSupplementalMetaData(new RecordReferenceTableMetaData().withTableNameField("tableName").withRecordIdField("recordId")
|
||||
.withFeature(feature).withAuthorField(author).withMirroredLocks(true));
|
||||
for(String keyType : config.getMirroredSecurityKeyTypes())
|
||||
{
|
||||
String field = RecordReferenceSecurityConfig.lockFieldName(keyType);
|
||||
table.withField(new QFieldMetaData(field, QFieldType.STRING).withLabel(keyType).withIsEditable(false));
|
||||
table.withRecordSecurityLock(new RecordSecurityLock().withFieldName(field).withSecurityKeyType(keyType)
|
||||
.withNullValueBehavior(RecordSecurityLock.NullValueBehavior.ALLOW));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
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.instances.QInstanceHelpContentManager;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish committed policy changes through the core overlay.
|
||||
*******************************************************************************/
|
||||
public class FeatureSettingsCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Refresh after successful insert.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsert(InsertInput input, List<QRecord> records) throws QException
|
||||
{
|
||||
refresh(input.getTransaction());
|
||||
return (records);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Refresh after successful update.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postUpdate(UpdateInput input, List<QRecord> records, Optional<List<QRecord>> oldRecords) throws QException
|
||||
{
|
||||
refresh(input.getTransaction());
|
||||
return (records);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Refresh after successful delete.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput input, List<QRecord> records) throws QException
|
||||
{
|
||||
refresh(input.getTransaction());
|
||||
return (records);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Never retain the completed transaction in a refresh callback.
|
||||
*******************************************************************************/
|
||||
private void refresh(QBackendTransaction transaction) throws QException
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
if(transaction == null)
|
||||
{
|
||||
refreshMetadata(instance);
|
||||
}
|
||||
else
|
||||
{
|
||||
var context = new CapturedContext(instance, QContext.getQSession());
|
||||
transaction.addAfterCommitCallback(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> refreshMetadata(instance));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not refresh collaboration policy", e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind persisted help after the overlay creates its dynamic fields and sections.
|
||||
*******************************************************************************/
|
||||
private static void refreshMetadata(QInstance instance) throws QException
|
||||
{
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
QInstanceHelpContentManager.reloadHelpContent(instance);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeatureOverrideProviderInterface;
|
||||
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.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.RecordFeatureOverride;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Read persisted feature policy during core overlay refresh.
|
||||
*******************************************************************************/
|
||||
public class FeatureSettingsProvider implements RecordFeatureOverrideProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Malformed policy rejects the contribution, retaining the last core snapshot.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<RecordFeatureOverride> getOverrides(QInstance instance) throws QException
|
||||
{
|
||||
List<RecordFeatureOverride> result = new ArrayList<>();
|
||||
for(var row : CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter()))
|
||||
{
|
||||
try
|
||||
{
|
||||
var override = new RecordFeatureOverride().withScopeType(RecordFeatureOverride.ScopeType.valueOf(row.getValueString("scopeType")))
|
||||
.withScopeName(row.getValueString("scopeName")).withFeature(RecordFeature.valueOf(row.getValueString("feature")))
|
||||
.withEnabled(row.getValueBoolean("enabled"));
|
||||
if(row.getValueString("permissionMode") != null)
|
||||
{
|
||||
override.withPermissionMode(FeaturePermissionMode.valueOf(row.getValueString("permissionMode")));
|
||||
}
|
||||
if(row.getValueString("settings") != null && !row.getValueString("settings").isBlank())
|
||||
{
|
||||
override.withSettings(new ObjectMapper().readValue(row.getValueString("settings"), new TypeReference<Map<String, Serializable>>() {}));
|
||||
}
|
||||
result.add(override);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Invalid collaboration feature setting", e));
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a feature against the incoming policy in core's instance/app/table order.
|
||||
*******************************************************************************/
|
||||
public static boolean enabled(QInstance instance, QTableMetaData table, RecordFeature feature, List<RecordFeatureOverride> overrides)
|
||||
{
|
||||
List<RecordFeaturesMetaData> levels = new ArrayList<>();
|
||||
List<String> apps = new ArrayList<>();
|
||||
levels.add(RecordFeaturesMetaData.of(instance));
|
||||
if(instance.getApps() != null)
|
||||
{
|
||||
for(var app : instance.getApps().values())
|
||||
{
|
||||
if(app.getSections() != null && app.getSections().stream().anyMatch(section -> section.getTables() != null && section.getTables().contains(table.getName())))
|
||||
{
|
||||
levels.add(RecordFeaturesMetaData.of(app));
|
||||
apps.add(app.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
levels.add(RecordFeaturesMetaData.of(table));
|
||||
boolean enabled = false;
|
||||
for(var level : levels)
|
||||
{
|
||||
if(level != null)
|
||||
{
|
||||
if(Boolean.FALSE.equals(level.getFeatureLocks().get(feature)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
enabled = level.getEnabled().getOrDefault(feature, enabled);
|
||||
}
|
||||
}
|
||||
for(var scope : RecordFeatureOverride.ScopeType.values())
|
||||
{
|
||||
List<String> scopes = scope == RecordFeatureOverride.ScopeType.APP ? apps : java.util.Collections.singletonList(scope == RecordFeatureOverride.ScopeType.TABLE ? table.getName() : null);
|
||||
for(String scopeName : scopes)
|
||||
{
|
||||
for(var override : overrides)
|
||||
{
|
||||
if(override.getFeature() == feature && override.getScopeType() == scope && override.getEnabled() != null
|
||||
&& (scope == RecordFeatureOverride.ScopeType.INSTANCE || (scope == RecordFeatureOverride.ScopeType.APP && scopeName.equals(override.getScopeName()))
|
||||
|| (scope == RecordFeatureOverride.ScopeType.TABLE && table.getName().equals(override.getScopeName()))))
|
||||
{
|
||||
enabled = override.getEnabled();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.util.Base64;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned opaque keyset cursor shared by comments and timeline.
|
||||
*******************************************************************************/
|
||||
public record RecordCursor(Instant at, String kind, Long id)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No record data is embedded in a cursor.
|
||||
*******************************************************************************/
|
||||
public String encode()
|
||||
{
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(("1\n" + at + "\n" + kind + "\n" + id).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject malformed cursors with a consistent service error.
|
||||
*******************************************************************************/
|
||||
public static RecordCursor decode(String cursor) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
String[] parts = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8).split("\n", -1);
|
||||
if(parts.length != 4 || !parts[0].equals("1") || !java.util.List.of("COMMENT", "ACTIVITY", "AUDIT").contains(parts[2]))
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid cursor"));
|
||||
}
|
||||
return (new RecordCursor(Instant.parse(parts[1]), parts[2], Long.valueOf(parts[3])));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
throw (new QBadRequestException("Invalid cursor", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict ordering for descending (time, kind, id) pagination.
|
||||
*******************************************************************************/
|
||||
public boolean isBefore(RecordCursor other)
|
||||
{
|
||||
int time = at.compareTo(other.at);
|
||||
int type = kind.compareTo(other.kind);
|
||||
return (time < 0 || (time == 0 && (type < 0 || (type == 0 && id < other.id))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the same tuple ordering in backend queries, including equal timestamps.
|
||||
*******************************************************************************/
|
||||
public QQueryFilter beforeFilter(String atField, String streamKind)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withSubFilter(new QQueryFilter().withCriteria(atField, QCriteriaOperator.LESS_THAN, at));
|
||||
if(streamKind.compareTo(kind) <= 0)
|
||||
{
|
||||
QQueryFilter equal = new QQueryFilter().withCriteria(atField, QCriteriaOperator.EQUALS, at);
|
||||
if(streamKind.equals(kind))
|
||||
{
|
||||
equal.withCriteria("id", QCriteriaOperator.LESS_THAN, id.toString());
|
||||
}
|
||||
filter.withSubFilter(equal);
|
||||
}
|
||||
return (filter);
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,13 @@ 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;
|
||||
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.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QValueException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
@@ -53,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.
|
||||
*******************************************************************************/
|
||||
@@ -81,7 +84,7 @@ public final class TargetResolver
|
||||
if(type == null || !TYPES.contains(type) || name == null || name.isBlank()
|
||||
|| ("RECORD".equals(type) ? id == null || id.isBlank() : id != null))
|
||||
{
|
||||
throw (new QException("Invalid target"));
|
||||
throw (new QBadRequestException("Invalid target"));
|
||||
}
|
||||
if("RECORD".equals(type))
|
||||
{
|
||||
@@ -94,7 +97,7 @@ public final class TargetResolver
|
||||
}
|
||||
catch(QValueException expected)
|
||||
{
|
||||
throw (new QException("Not found"));
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,13 +125,24 @@ public final class TargetResolver
|
||||
}
|
||||
if(!entry.available())
|
||||
{
|
||||
throw (new QException("Not found"));
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
return (entry);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
@@ -274,12 +288,16 @@ public final class TargetResolver
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard existence before invoking the core table permission helper.
|
||||
** Guard existence before invoking the core table permission helper. The read
|
||||
** is asked as the query it stands for: core hands a table's custom permission
|
||||
** checker the action input and not the permission asked, so a checker that
|
||||
** tells a read from a write by the input's type refuses an untyped input, and
|
||||
** its table was then no target for the people it lets read it.
|
||||
*******************************************************************************/
|
||||
private static boolean canReadTable(String table)
|
||||
{
|
||||
return (table != null && QContext.getQInstance().getTable(table) != null
|
||||
&& PermissionsHelper.hasTablePermission(new AbstractActionInput(), table, TablePermissionSubType.READ));
|
||||
&& PermissionsHelper.hasTablePermission(new QueryInput(table), table, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +314,15 @@ public final class TargetResolver
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared canonical record href, with optional configured frontend origin.
|
||||
*******************************************************************************/
|
||||
public static String recordHref(String table, String id)
|
||||
{
|
||||
String base = CollaborationRecords.config().getFrontendBaseUrl();
|
||||
return ((base == null ? "" : base.replaceAll("/+$", "")) + path(table) + encode(id));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Flat Next router path; trailing slash also supports dotted process names.
|
||||
*******************************************************************************/
|
||||
@@ -309,7 +336,7 @@ public final class TargetResolver
|
||||
/*******************************************************************************
|
||||
** Encode a single route segment, including slash, hash and spaces in ids.
|
||||
*******************************************************************************/
|
||||
private static String encode(String value)
|
||||
public static String encode(String value)
|
||||
{
|
||||
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeEvent;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
|
||||
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.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted after-delete hook. Access requests deliberately survive parent deletion.
|
||||
*******************************************************************************/
|
||||
public class WorkCleanup implements RecordChangeListenerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Host deletions only; extension deletes must not recursively invoke cleanup.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean appliesTo(String table, RecordChangeType type)
|
||||
{
|
||||
return (type == RecordChangeType.DELETE && !table.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Defer cleanup until the parent deletion commits.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onRecordsChanged(RecordChangeEvent event) throws QException
|
||||
{
|
||||
if(!appliesTo(event.getTableName(), event.getType()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String table = event.getTableName();
|
||||
String key = QContext.getQInstance().getTable(table).getPrimaryKeyField();
|
||||
List<Serializable> ids = event.getRecords().stream().map(row -> row.getValue(key)).toList();
|
||||
if(event.getTransaction() == null)
|
||||
{
|
||||
onParentsDeleted(table, ids);
|
||||
}
|
||||
else
|
||||
{
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
event.getTransaction().addAfterCommitCallback(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> onParentsDeleted(table, ids));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not clean up collaboration work", e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded batches and idempotent deletion also support explicit host callbacks.
|
||||
*******************************************************************************/
|
||||
public void onParentsDeleted(String table, List<? extends Serializable> ids) throws QException
|
||||
{
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(String child : List.of(CollaborationTables.TASK, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.SHARE))
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> rows = CollaborationRecords.cleanupQuery(child, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
|
||||
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()).withLimit(200));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
CollaborationRecords.cleanupDelete(child, row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.collaboration.assignment.AssignmentService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qbits.collaboration.sharing.AccessRequestService;
|
||||
import com.kingsrook.qbits.collaboration.tasks.TasksService;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.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.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
/*******************************************************************************
|
||||
** Streamed Review/Result actions. Services reauthorize all writes at load time.
|
||||
*******************************************************************************/
|
||||
public final class WorkProcesses
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata factory.
|
||||
*******************************************************************************/
|
||||
private WorkProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record actions stay on their source tables, never in app navigation.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> management()
|
||||
{
|
||||
return (List.of(process(CollaborationTables.ACCESS_REQUEST, "Grant", "Mark granted"), process(CollaborationTables.ACCESS_REQUEST, "Decline", "Decline"),
|
||||
process(CollaborationTables.TASK, "Done", "Mark done"), process(CollaborationTables.TASK, "Reopen", "Reopen")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Hosts opt in only tables without their own ownership columns.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> assignments(String table)
|
||||
{
|
||||
return (List.of(process(table, "Assign", "Assign to"), process(table, "Own", "Take ownership")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional notes and assignee input precede the standard Review screen.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData process(String table, String operation, String label)
|
||||
{
|
||||
boolean access = table.equals(CollaborationTables.ACCESS_REQUEST);
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, access
|
||||
? "Record the outcome; grant access in your identity system first." : label + " for the selected records.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(access ? AccessExtract.class : ExtractViaQueryStep.class, Review.class, Apply.class, values)
|
||||
.withName("collaboration" + operation + "_" + table).withLabel(label).withTableName(table).withIcon(new QIcon("assignment"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(ActionSecurity.class)));
|
||||
if(access || operation.equals("Assign"))
|
||||
{
|
||||
QFieldMetaData field = new QFieldMetaData(access ? "note" : "assigneeUserId", access ? QFieldType.TEXT : QFieldType.STRING)
|
||||
.withLabel(access ? "Note" : "Assignee user ID").withIsRequired(!access).withMaxLength(access ? 2000 : 255);
|
||||
process.withStep(0, new QFrontendStepMetaData().withName("details").withLabel(label).withFormField(field)
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Operation and table come from installed process metadata.
|
||||
*******************************************************************************/
|
||||
private static String operation(RunBackendStepInput input)
|
||||
{
|
||||
return (input.getProcess().getName().substring("collaboration".length()).split("_")[0]);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Share the same dry-run authorization between Review and load.
|
||||
*******************************************************************************/
|
||||
private static void validate(RunBackendStepInput input, QRecord row) throws QException
|
||||
{
|
||||
String table = input.getProcess().getTableName();
|
||||
if(table.equals(CollaborationTables.ACCESS_REQUEST))
|
||||
{
|
||||
new AccessRequestService().requireApprover(row.getValueInteger("id"));
|
||||
WorkSupport.text(input.getValueString("note"), 2000, false);
|
||||
}
|
||||
else if(table.equals(CollaborationTables.TASK))
|
||||
{
|
||||
new TasksService().requireManager(row.getValueInteger("id"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Serializable id = row.getValue(QContext.getQInstance().getTable(table).getPrimaryKeyField());
|
||||
ParentRecordAccess.requireFeature(table, id, "assign");
|
||||
if(operation(input).equals("Assign"))
|
||||
{
|
||||
WorkSupport.text(input.getValueString("assigneeUserId"), 255, true);
|
||||
WorkSupport.requireReader(input.getValueString("assigneeUserId"), table, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the role that the selected action does not modify.
|
||||
*******************************************************************************/
|
||||
private static void apply(RunBackendStepInput input, QRecord row) throws QException
|
||||
{
|
||||
validate(input, row);
|
||||
String table = input.getProcess().getTableName();
|
||||
String operation = operation(input);
|
||||
if(table.equals(CollaborationTables.ACCESS_REQUEST))
|
||||
{
|
||||
new AccessRequestService().handle(row.getValueInteger("id"), operation.equals("Grant") ? "GRANTED" : "DECLINED", input.getValueString("note"));
|
||||
}
|
||||
else if(table.equals(CollaborationTables.TASK))
|
||||
{
|
||||
if(operation.equals("Done"))
|
||||
{
|
||||
new TasksService().complete(row.getValueInteger("id"));
|
||||
}
|
||||
else
|
||||
{
|
||||
new TasksService().reopen(row.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Serializable id = row.getValue(QContext.getQInstance().getTable(table).getPrimaryKeyField());
|
||||
AssignmentService service = new AssignmentService();
|
||||
QRecord old = service.get(table, id).orElse(new QRecord());
|
||||
service.assign(table, id, operation.equals("Own") ? PersonalRecords.currentUserId() : old.getValueString("ownerUserId"),
|
||||
operation.equals("Assign") ? input.getValueString("assigneeUserId") : old.getValueString("assigneeUserId"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Consistent Review and Result counts.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String future, String past)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(future);
|
||||
line.setPluralFutureMessage(future);
|
||||
line.setSingularPastMessage(past);
|
||||
line.setPluralPastMessage(past);
|
||||
return (line);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Restrict action availability while keeping row authorization in the services.
|
||||
*******************************************************************************/
|
||||
public static class ActionSecurity implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Access actions require admin or a configured approver permission.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
PersonalRecords.currentUserId();
|
||||
String table = ((QProcessMetaData) metadata).getTableName();
|
||||
if(table.equals(CollaborationTables.ACCESS_REQUEST))
|
||||
{
|
||||
if(QContext.getQSession().hasPermission("collaboration.admin"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
for(String host : QContext.getQInstance().getTables().keySet())
|
||||
{
|
||||
if(!host.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX)
|
||||
&& QContext.getQSession().hasPermission(new AccessRequestService().approverPermission(host)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Could not resolve access approver permission", e));
|
||||
}
|
||||
throw (new QPermissionDeniedException("This needs the access approver permission."));
|
||||
}
|
||||
if(!table.equals(CollaborationTables.TASK))
|
||||
{
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireTableFeature(table, "assign");
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the table assign permission or EDIT.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Approvers can process requests without broadening requester/admin table reads.
|
||||
*******************************************************************************/
|
||||
public static class AccessExtract extends ExtractViaQueryStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Only authorized rows enter the process pipe or appear in its preview.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
int count = 0;
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.ACCESS_REQUEST, getQueryFilter(input)))
|
||||
{
|
||||
new AccessRequestService().requireApprover(row.getValueInteger("id"));
|
||||
getRecordPipe().addRecord(row);
|
||||
count++;
|
||||
if(getLimit() != null && count >= getLimit())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Avoid exposing an unfiltered count outside the table owner lock.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer doCount(RunBackendStepInput input)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review validates recipient access and authority without writing.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine selected = line(Status.OK, "records will be processed", "records processed");
|
||||
private final ProcessSummaryLine skipped = line(Status.WARNING, "records unavailable (skipped)", "records skipped");
|
||||
|
||||
/*******************************************************************************
|
||||
** Only validated rows advance to load.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(input.getProcess().getTableName().equals(CollaborationTables.ACCESS_REQUEST))
|
||||
{
|
||||
selected.setSingularFutureMessage("Record the outcome; grant access in your identity system first.");
|
||||
selected.setPluralFutureMessage("Record the outcome; grant access in your identity system first.");
|
||||
}
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
try
|
||||
{
|
||||
validate(input, row);
|
||||
output.getRecords().add(row);
|
||||
selected.incrementCountAndAddPrimaryKey(row.getValue(QContext.getQInstance().getTable(input.getProcess().getTableName()).getPrimaryKeyField()));
|
||||
}
|
||||
catch(QException expected)
|
||||
{
|
||||
skipped.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit identity-system prerequisite accompanies access-request reviews.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
selected.addSelfToListIfAnyCount(lines);
|
||||
skipped.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Result reports actual writes and any permission changes since Review.
|
||||
*******************************************************************************/
|
||||
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine done = line(Status.OK, "records will be processed", "records processed");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "records cannot be processed", "records failed (retry required)");
|
||||
|
||||
/*******************************************************************************
|
||||
** Each write goes through its service authorization again.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
try
|
||||
{
|
||||
apply(input, row);
|
||||
done.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
catch(QException expected)
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain Review skips alongside successful and failed loads.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
done.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
if(getTransformStep() instanceof Review review)
|
||||
{
|
||||
review.skipped.addSelfToListIfAnyCount(lines);
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.collaboration.people.PeopleService;
|
||||
import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
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.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared validation for service-managed work records.
|
||||
*******************************************************************************/
|
||||
public final class WorkSupport
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static helpers only.
|
||||
*******************************************************************************/
|
||||
private WorkSupport()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up an extension row and enforce its parent before returning data.
|
||||
*******************************************************************************/
|
||||
public static QRecord visible(String table, Integer id) throws QException
|
||||
{
|
||||
QRecord row = row(table, id);
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("parentDeleted")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted lookup: callers must authorize ownership or parent access themselves.
|
||||
*******************************************************************************/
|
||||
public static QRecord row(String table, Integer id) throws QException
|
||||
{
|
||||
List<QRecord> rows = CollaborationRecords.query(table, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Self passes after the caller's parent check; unknown others fail closed.
|
||||
*******************************************************************************/
|
||||
public static void requireReader(String user, String table, Serializable id) throws QException
|
||||
{
|
||||
if(user != null && (user.isBlank() || (!user.equals(PersonalRecords.currentUserId())
|
||||
&& !new CollaborationRecordAccessChecker().canRead(user, table, id).orElse(false))))
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException(new PeopleService().displayName(user) + " can't open this record."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded text validation before any write or notification.
|
||||
*******************************************************************************/
|
||||
public static void text(String value, int max, boolean required) throws QException
|
||||
{
|
||||
if((required && (value == null || value.isBlank())) || (value != null && value.length() > max))
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Text must contain " + (required ? "1" : "0") + " to " + max + " characters."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.activity;
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
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.collaboration.RecordFeature;
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal activity writer; the calling service has already checked the parent.
|
||||
*******************************************************************************/
|
||||
public final class ActivityWriter
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static service boundary.
|
||||
*******************************************************************************/
|
||||
private ActivityWriter()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Store references, never copies of comment bodies or attachment contents.
|
||||
*******************************************************************************/
|
||||
public static void write(String table, String recordId, String type, String summary, String refTable, String refId) throws QException
|
||||
{
|
||||
CollaborationRecords.insertActivity(new QRecord().withValue("tableName", table).withValue("recordId", recordId)
|
||||
.withValue("at", Instant.now()).withValue("actorUserId", CollaborationSecurity.userId(QContext.getQSession()))
|
||||
.withValue("type", type).withValue("summary", summary).withValue("refTableName", refTable).withValue("refId", refId).withValue("parentDeleted", false), sourceFeature(refTable));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Source-specific activity requires that feature; generic events retain the
|
||||
** existing timeline authorization contract.
|
||||
*******************************************************************************/
|
||||
private static RecordFeature sourceFeature(String table)
|
||||
{
|
||||
return (switch(table == null ? "" : table)
|
||||
{
|
||||
case CollaborationTables.ATTACHMENT -> RecordFeature.ATTACHMENTS;
|
||||
case CollaborationTables.COMMENT, CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION -> RecordFeature.COMMENTS;
|
||||
case CollaborationTables.TAG, CollaborationTables.RECORD_TAG -> RecordFeature.TAGS;
|
||||
case CollaborationTables.FIELD_DEFINITION, CollaborationTables.FIELD_VALUE -> RecordFeature.CUSTOM_FIELDS;
|
||||
case CollaborationTables.TASK -> RecordFeature.TASKS;
|
||||
case CollaborationTables.RECORD_ASSIGNMENT -> RecordFeature.ASSIGNMENT;
|
||||
case CollaborationTables.FOLLOW, CollaborationTables.FOLLOW_DIGEST -> RecordFeature.FOLLOW;
|
||||
case CollaborationTables.SHARE -> RecordFeature.SHARING;
|
||||
default -> RecordFeature.TIMELINE;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationTagApplyRequestV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationTagListV1;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagsService;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add and remove record tags.
|
||||
*******************************************************************************/
|
||||
public class ApplyTagsSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.PUT, "/records/{table}/{pk}/tags", "Add and remove record tags"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationTagListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationTagApplyRequestV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationTagApplyRequestV1.class);
|
||||
new TagsService().apply(table(input), pk(input), ids(body.add()), ids(body.remove()));
|
||||
return (new CollaborationTagListV1(new TagsService().tagsFor(table(input), pk(input)).stream().map(CollaborationViews::tag).toList(), null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the collaboration UI API extension on a host instance.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationApi
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationApi()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Install the extension; the producer calls this when uiApi is enabled.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The standalone descriptor used by registration and contract generation.
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
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)
|
||||
.withSpec(RecordRecentSpec.class)
|
||||
.withSpec(OrderFavoritesSpec.class)
|
||||
.withSpec(ListRecentsSpec.class)
|
||||
.withSpec(ListAttachmentsSpec.class)
|
||||
.withSpec(DownloadAttachmentSpec.class)
|
||||
.withSpec(PreviewAttachmentSpec.class)
|
||||
.withSpec(PatchAttachmentSpec.class)
|
||||
.withSpec(DeleteAttachmentSpec.class)
|
||||
.withSpec(ListTagsSpec.class)
|
||||
.withSpec(CreateTagSpec.class)
|
||||
.withSpec(ListRecordTagsSpec.class)
|
||||
.withSpec(ApplyTagsSpec.class)
|
||||
.withSpec(ListCommentsSpec.class)
|
||||
.withSpec(PostCommentSpec.class)
|
||||
.withSpec(EditCommentSpec.class)
|
||||
.withSpec(DeleteCommentSpec.class)
|
||||
.withSpec(ResolveCommentSpec.class)
|
||||
.withSpec(ReopenCommentSpec.class)
|
||||
.withSpec(CommentHistorySpec.class)
|
||||
.withSpec(MentionCandidatesSpec.class)
|
||||
.withSpec(GetFollowSpec.class)
|
||||
.withSpec(PutFollowSpec.class)
|
||||
.withSpec(DeleteFollowSpec.class)
|
||||
.withSpec(ListFollowersSpec.class)
|
||||
.withSpec(GetTimelineSpec.class)
|
||||
.withSpec(ListPeopleSpec.class)
|
||||
.withSpec(GetPersonSpec.class)
|
||||
.withSpec(GetRecordLinkSpec.class)
|
||||
.withSpec(PreviewSharesSpec.class)
|
||||
.withSpec(CreateSharesSpec.class)
|
||||
.withSpec(CreateAccessRequestSpec.class)
|
||||
.withSpec(ListMyAccessRequestsSpec.class)
|
||||
.withSpec(ListTasksSpec.class)
|
||||
.withSpec(CreateTaskSpec.class)
|
||||
.withSpec(PatchTaskSpec.class)
|
||||
.withSpec(ListMyTasksSpec.class)
|
||||
.withSpec(PutAssignmentSpec.class)
|
||||
.withSpec(GetMyWorkSpec.class)
|
||||
.withSpec(MarkMentionsReadSpec.class)
|
||||
.withSpec(UploadAttachmentSpec.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
/*
|
||||
* 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.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.cfg.CoercionAction;
|
||||
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import com.fasterxml.jackson.databind.type.LogicalType;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationMutationResultV1;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
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;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Common JSON transport declarations and input validation; services own authorization.
|
||||
*******************************************************************************/
|
||||
abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
private static final ObjectMapper MAPPER = mapper();
|
||||
|
||||
/*******************************************************************************
|
||||
** Jackson's scalar-coercion flag alone still permits numbers as text.
|
||||
*******************************************************************************/
|
||||
private static ObjectMapper mapper()
|
||||
{
|
||||
ObjectMapper mapper = JsonMapper.builder().disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT).build();
|
||||
mapper.coercionConfigFor(LogicalType.Textual)
|
||||
.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
|
||||
.setCoercion(CoercionInputShape.Float, CoercionAction.Fail)
|
||||
.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail);
|
||||
return (mapper);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Check record visibility before decoding a body or paging parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public JsonInput buildInput(Context context) throws Exception
|
||||
{
|
||||
if(context.pathParamMap().containsKey("table"))
|
||||
{
|
||||
ParentRecordAccess.requireVisibleParent(context.pathParam("table"), context.pathParam("pk"));
|
||||
}
|
||||
return (super.buildInput(context));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Extension-relative operation with semantic client documentation.
|
||||
*******************************************************************************/
|
||||
static BasicOperation operation(HttpMethod method, String path, String summary)
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(method).withPath("/qbit/collaboration" + path)
|
||||
.withShortSummary(summary).withLongDescription(summary + ". Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare the framework's standard error bodies without replacing its mapping.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (errors());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared error contract, including the multipart operation.
|
||||
*******************************************************************************/
|
||||
static List<BasicResponse> errors()
|
||||
{
|
||||
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())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Path parameters and explicitly supported query parameters.
|
||||
*******************************************************************************/
|
||||
protected List<Parameter> parameters(String... queries)
|
||||
{
|
||||
return (parametersFor(defineBasicOperation().getPath(), queries));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parameter descriptions are shared with multipart upload.
|
||||
*******************************************************************************/
|
||||
static List<Parameter> parametersFor(String path, String... queries)
|
||||
{
|
||||
List<Parameter> result = new ArrayList<>();
|
||||
Matcher matcher = Pattern.compile("\\{([^}]+)}").matcher(path);
|
||||
while(matcher.find())
|
||||
{
|
||||
String name = matcher.group(1);
|
||||
result.add(parameter(name, In.PATH, true));
|
||||
}
|
||||
for(String query : queries)
|
||||
{
|
||||
result.add(parameter(query.replace("!", ""), In.QUERY, query.endsWith("!")));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Semantic hints for clients and command-line help.
|
||||
*******************************************************************************/
|
||||
private static Parameter parameter(String name, In location, boolean required)
|
||||
{
|
||||
String description = switch(name)
|
||||
{
|
||||
case "table" -> "Parent table metadata name.";
|
||||
case "pk" -> "Parent record primary key.";
|
||||
case "id" -> "Collaboration object identifier.";
|
||||
case "userId" -> "Stable directory user identity.";
|
||||
case "limit" -> "Maximum items to return, from 1 to 100; default 20.";
|
||||
case "cursor" -> "Opaque nextCursor returned by the previous page; omit for the first page.";
|
||||
case "before" -> "Timeline continuation token; alias for cursor. Supply only one.";
|
||||
case "includeDone" -> "Include completed and cancelled tasks; default false.";
|
||||
case "status" -> "Task status: OPEN (default), DONE or CANCELLED.";
|
||||
case "types" -> "Comma-separated AUDIT, ACTIVITY or activity type names (for example COMMENTED).";
|
||||
case "q" -> "Text to search in display names or vocabulary.";
|
||||
default -> name;
|
||||
};
|
||||
return (new Parameter().withName(name).withIn(location).withRequired(required).withDescription(description)
|
||||
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") ? Type.BOOLEAN : Type.STRING)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject malformed types instead of silently coercing client values.
|
||||
*******************************************************************************/
|
||||
protected static <T> T body(JsonInput input, Class<T> type) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
if(input.getBody() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Object body required"));
|
||||
}
|
||||
return (MAPPER.convertValue(input.getBody(), type));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QBadRequestException("Invalid " + type.getSimpleName() + " body: check the property names and types."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the shared page bound.
|
||||
*******************************************************************************/
|
||||
protected static int limit(JsonInput input) throws QBadRequestException
|
||||
{
|
||||
Integer limit = input.getQueryParamInteger("limit");
|
||||
if(limit == null)
|
||||
{
|
||||
return (20);
|
||||
}
|
||||
if(limit < 1 || limit > 100)
|
||||
{
|
||||
throw (new QBadRequestException("Limit must be between 1 and 100"));
|
||||
}
|
||||
return (limit);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Page complete, permission-filtered service lists; recheck on every request.
|
||||
** These cursors are offsets, not a snapshot across concurrent mutations.
|
||||
*******************************************************************************/
|
||||
protected static <T> Page<T> page(JsonInput input, List<T> values) throws QBadRequestException
|
||||
{
|
||||
int limit = limit(input);
|
||||
PagePosition position = pagePosition(input);
|
||||
int start = Math.min(position.offset(), values.size());
|
||||
int end = start + Math.min(limit, values.size() - start);
|
||||
String next = end < values.size() ? position.cursor(end) : null;
|
||||
return (new Page<>(values.subList(start, end), next));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared existing offset cursor contract, also used by bounded native lists.
|
||||
** Scope is a routing check, never authorization or a signed capability.
|
||||
*******************************************************************************/
|
||||
protected static PagePosition pagePosition(JsonInput input) throws QBadRequestException
|
||||
{
|
||||
int offset = 0;
|
||||
String scope = input.getSpec().defineBasicOperation().getPath() + input.getPathParams().toString()
|
||||
+ ":" + input.getQueryParam("table") + ":" + input.getQueryParam("pk") + ":" + input.getQueryParam("q");
|
||||
String prefix = "1:" + Integer.toHexString(scope.hashCode()) + ":";
|
||||
String cursor = input.getQueryParam("cursor");
|
||||
if(cursor != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
String decoded = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
|
||||
if(!decoded.startsWith(prefix))
|
||||
{
|
||||
throw (new IllegalArgumentException("Wrong cursor scope"));
|
||||
}
|
||||
offset = Integer.parseInt(decoded.substring(prefix.length()));
|
||||
if(offset < 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("Negative offset"));
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QBadRequestException("Invalid cursor"));
|
||||
}
|
||||
}
|
||||
return (new PagePosition(offset, prefix));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Same version, scope and offset bytes as previously emitted by page().
|
||||
*******************************************************************************/
|
||||
protected record PagePosition(int offset, String prefix)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Encode only a representable nonnegative continuation offset.
|
||||
*******************************************************************************/
|
||||
String cursor(long next) throws QBadRequestException
|
||||
{
|
||||
if(next < 0 || next > Integer.MAX_VALUE)
|
||||
{
|
||||
throw (new QBadRequestException("Invalid cursor"));
|
||||
}
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString((prefix + next).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal page shape before projecting the specific wire type.
|
||||
*******************************************************************************/
|
||||
protected record Page<T>(List<T> items, String nextCursor)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A required nonblank string.
|
||||
*******************************************************************************/
|
||||
protected static String required(String value, String name) throws QBadRequestException
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
throw (new QBadRequestException(name + " is required"));
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parent table name.
|
||||
*******************************************************************************/
|
||||
protected static String table(JsonInput input)
|
||||
{
|
||||
return (input.getPathParam("table"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parent key remains a string until the service canonicalizes its field type.
|
||||
*******************************************************************************/
|
||||
protected static String pk(JsonInput input)
|
||||
{
|
||||
return (input.getPathParam("pk"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Collaboration tables use positive integer identifiers.
|
||||
*******************************************************************************/
|
||||
protected static Integer id(JsonInput input) throws QBadRequestException
|
||||
{
|
||||
return (positiveId(input.getPathParam("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict identifier parsing, also used for folder-map keys.
|
||||
*******************************************************************************/
|
||||
private static Integer positiveId(String value) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
int id = Integer.parseInt(value);
|
||||
if(id < 1)
|
||||
{
|
||||
throw (new NumberFormatException());
|
||||
}
|
||||
return (id);
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException("An identifier must be a positive integer"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional add/remove lists may be empty, but never contain null identifiers.
|
||||
*******************************************************************************/
|
||||
protected static List<Integer> ids(List<Integer> values) throws QBadRequestException
|
||||
{
|
||||
if(values == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
if(values.stream().anyMatch(id -> id == null || id < 1))
|
||||
{
|
||||
throw (new QBadRequestException("Tag identifiers must be positive integers"));
|
||||
}
|
||||
return (values);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert JSON object keys without trusting arbitrary numeric coercion.
|
||||
*******************************************************************************/
|
||||
protected static Map<Integer, String> folders(Map<String, String> values) throws QBadRequestException
|
||||
{
|
||||
Map<Integer, String> result = new LinkedHashMap<>();
|
||||
if(values != null)
|
||||
{
|
||||
for(Map.Entry<String, String> entry : values.entrySet())
|
||||
{
|
||||
result.put(positiveId(entry.getKey()), entry.getValue());
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the timeline's before name and the common cursor convention.
|
||||
*******************************************************************************/
|
||||
protected static String timelineCursor(JsonInput input) throws QBadRequestException
|
||||
{
|
||||
if(input.getQueryParam("before") != null && input.getQueryParam("cursor") != null)
|
||||
{
|
||||
throw (new QBadRequestException("Supply before or cursor, not both"));
|
||||
}
|
||||
return (input.getQueryParam("before") == null ? input.getQueryParam("cursor") : input.getQueryParam("before"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Timeline accepts source names or collaboration activity names.
|
||||
*******************************************************************************/
|
||||
protected static Set<String> types(JsonInput input)
|
||||
{
|
||||
String types = input.getQueryParam("types");
|
||||
return (types == null || types.isBlank() ? Set.of() : Arrays.stream(types.split(",")).map(String::strip).collect(Collectors.toSet()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Consistent acknowledgement for operations with no service return value.
|
||||
*******************************************************************************/
|
||||
protected static CollaborationMutationResultV1 success()
|
||||
{
|
||||
return (new CollaborationMutationResultV1(true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a nullable ISO-8601 timestamp.
|
||||
*******************************************************************************/
|
||||
protected static java.time.Instant instant(String value) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (value == null ? null : java.time.Instant.parse(value));
|
||||
}
|
||||
catch(java.time.format.DateTimeParseException e)
|
||||
{
|
||||
throw (new QBadRequestException("Expected an ISO-8601 timestamp"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient lists are bounded and contain only nonblank identities.
|
||||
*******************************************************************************/
|
||||
protected static List<String> recipients(List<String> values) throws QBadRequestException
|
||||
{
|
||||
if(values == null || values.isEmpty() || values.size() > 25 || values.stream().anyMatch(value -> value == null || value.isBlank()))
|
||||
{
|
||||
throw (new QBadRequestException("Choose 1 to 25 recipients"));
|
||||
}
|
||||
return (values);
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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 java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned presence requests, polling snapshots, and realtime event data.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationPresenceSchemas
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Schema namespace.
|
||||
*******************************************************************************/
|
||||
private 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, 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.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Renew a 45-second presence lease; send every 20 seconds.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"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),
|
||||
"sequence", sequenceSchema()))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave removes only this authenticated user's connection and target.
|
||||
*******************************************************************************/
|
||||
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.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
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), "sequence", sequenceSchema()))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The snapshot is also the data shape of presence.changed events.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A table snapshot deliberately excludes record ids and focused field names.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Live viewers, merged across tabs; presence.changed uses this payload.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"table", text("Parent table name.", false), "pk", text("Record key; absent for table events.", true),
|
||||
"viewers", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only public identity and focus are carried, never field contents.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Editing takes precedence when one user has several open tabs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs; EDITING takes precedence.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"userId", text("Stable user identity.", false), "displayName", text("Session display name.", false),
|
||||
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true)))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse scalar schema descriptions consistently.
|
||||
*******************************************************************************/
|
||||
private static Schema text(String description, boolean nullable)
|
||||
{
|
||||
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."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned wire records and explicit schemas, independent of persistence DTOs.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationSchemas
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationSchemas()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A saved navigation target, with availability rechecked for the caller.
|
||||
*******************************************************************************/
|
||||
public record CollaborationFavoriteV1(Integer id, String targetType, String targetName, String recordId, String label, String folder, Integer sortOrder, Boolean available, String href) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("A saved navigation target, with availability rechecked for the caller.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Favorite identifier.").withNullable(true)),
|
||||
Map.entry("targetType", new Schema().withType(Type.STRING).withEnumValues(List.of("TABLE","APP","RECORD","PROCESS","REPORT","SAVED_VIEW")).withDescription("Kind of navigation target.").withNullable(true)),
|
||||
Map.entry("targetName", new Schema().withType(Type.STRING).withDescription("Metadata name of the table, app, process, report or saved view.").withNullable(true)),
|
||||
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("Canonical record key; null for non-record targets.").withNullable(true)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING).withDescription("Current display label; Unavailable when access is lost.").withNullable(true)),
|
||||
Map.entry("folder", new Schema().withType(Type.STRING).withDescription("Optional personal grouping.").withNullable(true)),
|
||||
Map.entry("sortOrder", new Schema().withType(Type.INTEGER).withDescription("Position in the personal list.").withNullable(true)),
|
||||
Map.entry("available", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller can currently open this target.").withNullable(true)),
|
||||
Map.entry("href", new Schema().withType(Type.STRING).withDescription("Navigation link, absent for unavailable targets.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An attachment visible through its parent record; no storage coordinates are exposed.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName, Boolean canEdit) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve construction compatibility with the earlier attachment body.
|
||||
*******************************************************************************/
|
||||
public CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType,
|
||||
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName)
|
||||
{
|
||||
this(id, tableName, recordId, fileName, contentType, sizeBytes, sha256, description, createdByUserId, createDate, canDelete, createdByDisplayName, canDelete);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("An attachment visible through its parent record; no storage coordinates are exposed.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Attachment identifier.").withNullable(true)),
|
||||
Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("Parent table metadata name.").withNullable(true)),
|
||||
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("Canonical parent record key.").withNullable(true)),
|
||||
Map.entry("fileName", new Schema().withType(Type.STRING).withDescription("Safe display filename.").withNullable(true)),
|
||||
Map.entry("contentType", new Schema().withType(Type.STRING).withDescription("MIME content type.").withNullable(true)),
|
||||
Map.entry("sizeBytes", new Schema().withType(Type.INTEGER).withDescription("Stored byte count.").withNullable(true)),
|
||||
Map.entry("sha256", new Schema().withType(Type.STRING).withDescription("SHA-256 content digest.").withNullable(true)),
|
||||
Map.entry("description", new Schema().withType(Type.STRING).withDescription("Optional description.").withNullable(true)),
|
||||
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("Uploader identity.").withNullable(true)),
|
||||
Map.entry("createDate", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Upload time.").withNullable(true)),
|
||||
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may delete this attachment; retained files cannot be deleted.").withNullable(true)),
|
||||
Map.entry("canEdit", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change the attachment filename or description.").withNullable(true)),
|
||||
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Uploader display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An approved tag or the caller's pending suggestion.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTagV1(Integer id, String name, String label, String color, String tableName, Boolean approved, Integer usageCount) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("An approved tag or the caller's pending suggestion.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Tag identifier.").withNullable(true)),
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withDescription("Normalized vocabulary name.").withNullable(true)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING).withDescription("Display label.").withNullable(true)),
|
||||
Map.entry("color", new Schema().withType(Type.STRING).withDescription("Optional display color.").withNullable(true)),
|
||||
Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("Table scope; null means global.").withNullable(true)),
|
||||
Map.entry("approved", new Schema().withType(Type.BOOLEAN).withDescription("Whether anyone may apply this tag.").withNullable(true)),
|
||||
Map.entry("usageCount", new Schema().withType(Type.INTEGER).withDescription("Number of record associations.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An active directory entry.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPersonV1(String userId, String displayName, String email, Instant lastSeenAt) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("An active directory entry.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("userId", new Schema().withType(Type.STRING).withDescription("Stable user identity.").withNullable(true)),
|
||||
Map.entry("displayName", new Schema().withType(Type.STRING).withDescription("Display name.").withNullable(true)),
|
||||
Map.entry("email", new Schema().withType(Type.STRING).withDescription("Directory email, if known.").withNullable(true)),
|
||||
Map.entry("lastSeenAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Most recent directory refresh time.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A mention embedded in a comment.
|
||||
*******************************************************************************/
|
||||
public record CollaborationMentionV1(String userId, Boolean visible) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("A mention embedded in a comment.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("userId", new Schema().withType(Type.STRING).withDescription("Mentioned user identity.").withNullable(true)),
|
||||
Map.entry("visible", new Schema().withType(Type.BOOLEAN).withDescription("Whether recipient access was confirmed when mentioned.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A matching person and their known parent access.
|
||||
*******************************************************************************/
|
||||
public record CollaborationMentionCandidateV1(CollaborationPersonV1 person, String canRead) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("A matching person and their known parent access.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("person", new Schema().withRefToSchema("CollaborationPersonV1").withDescription("Directory identity.").withNullable(true)),
|
||||
Map.entry("canRead", new Schema().withType(Type.STRING).withEnumValues(List.of("true","false","unknown")).withDescription("Recipient access: string true, false, or unknown when it cannot be established.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A comment or reply; deleted comments retain their position without body text.
|
||||
*******************************************************************************/
|
||||
public record CollaborationCommentV1(Integer id, Integer threadId, Integer parentCommentId, String body, String fieldName, String authorUserId, String authorDisplayName, Instant createdAt, Instant editedAt, Boolean deleted, Boolean resolved, Boolean canEdit, Boolean canDelete, List<CollaborationMentionV1> mentions, List<CollaborationCommentV1> replies) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("A comment or reply; deleted comments retain their position without body text.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Comment identifier.").withNullable(true)),
|
||||
Map.entry("threadId", new Schema().withType(Type.INTEGER).withDescription("Root thread identifier; null on roots.").withNullable(true)),
|
||||
Map.entry("parentCommentId", new Schema().withType(Type.INTEGER).withDescription("Root comment for a reply; null on roots.").withNullable(true)),
|
||||
Map.entry("body", new Schema().withType(Type.STRING).withDescription("Plain text with mention tokens; null for deleted comments.").withNullable(true)),
|
||||
Map.entry("fieldName", new Schema().withType(Type.STRING).withDescription("Optional field being discussed.").withNullable(true)),
|
||||
Map.entry("authorUserId", new Schema().withType(Type.STRING).withDescription("Author identity.").withNullable(true)),
|
||||
Map.entry("authorDisplayName", new Schema().withType(Type.STRING).withDescription("Author display name.").withNullable(true)),
|
||||
Map.entry("createdAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Creation time.").withNullable(true)),
|
||||
Map.entry("editedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Last edit time, if edited.").withNullable(true)),
|
||||
Map.entry("deleted", new Schema().withType(Type.BOOLEAN).withDescription("Whether the comment is soft deleted.").withNullable(true)),
|
||||
Map.entry("resolved", new Schema().withType(Type.BOOLEAN).withDescription("Whether the thread is resolved.").withNullable(true)),
|
||||
Map.entry("canEdit", new Schema().withType(Type.BOOLEAN).withDescription("Caller may edit the body.").withNullable(true)),
|
||||
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Caller may delete this comment.").withNullable(true)),
|
||||
Map.entry("mentions", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationMentionV1")).withDescription("Mention recipients.").withNullable(true)),
|
||||
Map.entry("replies", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationCommentV1")).withDescription("Replies in oldest-first order; empty on replies.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A previous comment body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationCommentRevisionV1(Integer id, String body, Instant editedAt, String editedByUserId, String editedByDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("A previous comment body.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Revision identifier.").withNullable(true)),
|
||||
Map.entry("body", new Schema().withType(Type.STRING).withDescription("Previous body text.").withNullable(true)),
|
||||
Map.entry("editedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Time the body was replaced.").withNullable(true)),
|
||||
Map.entry("editedByUserId", new Schema().withType(Type.STRING).withDescription("Editor identity.").withNullable(true)),
|
||||
Map.entry("editedByDisplayName", new Schema().withType(Type.STRING).withDescription("Editor display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A subscription to record changes and comments.
|
||||
*******************************************************************************/
|
||||
public record CollaborationFollowV1(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil, String userDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("A subscription to record changes and comments.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Subscription identifier.").withNullable(true)),
|
||||
Map.entry("userId", new Schema().withType(Type.STRING).withDescription("Follower identity.").withNullable(true)),
|
||||
Map.entry("reason", new Schema().withType(Type.STRING).withEnumValues(List.of("MANUAL", "AUTHOR", "COMMENTED", "MENTIONED", "ASSIGNED", "SHARED")).withDescription("How following began.").withNullable(true)),
|
||||
Map.entry("notifyOnChange", new Schema().withType(Type.BOOLEAN).withDescription("Receive record change digests.").withNullable(true)),
|
||||
Map.entry("notifyOnComment", new Schema().withType(Type.BOOLEAN).withDescription("Receive new comment notifications.").withNullable(true)),
|
||||
Map.entry("mutedUntil", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Notifications muted until this time, if set.").withNullable(true)),
|
||||
Map.entry("userDisplayName", new Schema().withType(Type.STRING).withDescription("Follower display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The current caller's subscription state.
|
||||
*******************************************************************************/
|
||||
public record CollaborationFollowStateV1(Boolean following, CollaborationFollowV1 follow) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("The current caller's subscription state.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("following", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller follows the record.").withNullable(true)),
|
||||
Map.entry("follow", new Schema().withRefToSchema("CollaborationFollowV1").withDescription("Subscription settings; null when not following.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A permitted field change from a core audit.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTimelineDetailV1(String fieldName, String oldValue, String newValue, String message) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("A permitted field change from a core audit.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("fieldName", new Schema().withType(Type.STRING).withDescription("Changed field metadata name.").withNullable(true)),
|
||||
Map.entry("oldValue", new Schema().withType(Type.STRING).withDescription("Previous display value.").withNullable(true)),
|
||||
Map.entry("newValue", new Schema().withType(Type.STRING).withDescription("New display value.").withNullable(true)),
|
||||
Map.entry("message", new Schema().withType(Type.STRING).withDescription("Optional audit message.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One visible event in the merged record timeline.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTimelineItemV1(String kind, Long id, Instant at, String actorUserId, String actorName, String summary, List<CollaborationTimelineDetailV1> details, String refTable, String refId) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One visible event in the merged record timeline.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("kind", new Schema().withType(Type.STRING).withEnumValues(List.of("ACTIVITY","AUDIT")).withDescription("Event source.").withNullable(true)),
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Identifier within the source.").withNullable(true)),
|
||||
Map.entry("at", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Event time.").withNullable(true)),
|
||||
Map.entry("actorUserId", new Schema().withType(Type.STRING).withDescription("Actor identity, when known.").withNullable(true)),
|
||||
Map.entry("actorName", new Schema().withType(Type.STRING).withDescription("Actor display name.").withNullable(true)),
|
||||
Map.entry("summary", new Schema().withType(Type.STRING).withDescription("Human-readable event summary.").withNullable(true)),
|
||||
Map.entry("details", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationTimelineDetailV1")).withDescription("Field details filtered by caller permissions.").withNullable(true)),
|
||||
Map.entry("refTable", new Schema().withType(Type.STRING).withDescription("Related table metadata name.").withNullable(true)),
|
||||
Map.entry("refId", new Schema().withType(Type.STRING).withDescription("Related record key.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered personal favorites.
|
||||
*******************************************************************************/
|
||||
public record CollaborationFavoriteListV1(List<CollaborationFavoriteV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Ordered personal favorites.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationFavoriteV1")).withDescription("Ordered personal favorites.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("Opaque continuation token; null when finished or the service has no continuation support.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Most recent visible targets, at most fifty.
|
||||
*******************************************************************************/
|
||||
public record CollaborationRecentListV1(List<CollaborationFavoriteV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Most recent visible targets, at most fifty.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationFavoriteV1")).withDescription("Most recent visible targets, at most fifty.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("Opaque continuation token; null when finished or the service has no continuation support.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Attachments, newest first.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAttachmentListV1(List<CollaborationAttachmentV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Attachments, newest first.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationAttachmentV1")).withDescription("Attachments, newest first.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("Opaque continuation token; null when finished or the service has no continuation support.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Available or applied tags.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTagListV1(List<CollaborationTagV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Available or applied tags.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationTagV1")).withDescription("Available or applied tags.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("Opaque continuation token; null when finished or the service has no continuation support.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Matching active directory entries.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPersonListV1(List<CollaborationPersonV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Matching active directory entries.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationPersonV1")).withDescription("Matching active directory entries.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("Opaque continuation token; null when finished or the service has no continuation support.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Candidates ranked with record participants first.
|
||||
*******************************************************************************/
|
||||
public record CollaborationMentionCandidateListV1(List<CollaborationMentionCandidateV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Candidates ranked with record participants first.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationMentionCandidateV1")).withDescription("Candidates ranked with record participants first.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("Opaque continuation token; null when finished or the service has no continuation support.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Root threads by latest activity, with replies.
|
||||
*******************************************************************************/
|
||||
public record CollaborationCommentPageV1(List<CollaborationCommentV1> threads, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Root threads by latest activity, with replies.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("threads", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationCommentV1")).withDescription("Root threads by latest activity, with replies.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("Opaque continuation token; null when finished or the service has no continuation support.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Previous bodies in edit order.
|
||||
*******************************************************************************/
|
||||
public record CollaborationCommentHistoryV1(List<CollaborationCommentRevisionV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Previous bodies in edit order.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationCommentRevisionV1")).withDescription("Previous bodies in edit order.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("Opaque continuation token; null when finished or the service has no continuation support.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Subscriptions for this record.
|
||||
*******************************************************************************/
|
||||
public record CollaborationFollowerListV1(List<CollaborationFollowV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Subscriptions for this record.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationFollowV1")).withDescription("Subscriptions for this record.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("Opaque continuation token; null when finished or the service has no continuation support.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Merged events, newest first.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTimelinePageV1(List<CollaborationTimelineItemV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Merged events, newest first.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationTimelineItemV1")).withDescription("Merged events, newest first.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("Opaque continuation token; null when finished or the service has no continuation support.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Acknowledgement of a completed mutation.
|
||||
*******************************************************************************/
|
||||
public record CollaborationMutationResultV1(Boolean success) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Acknowledgement of a completed mutation.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("success", new Schema().withType(Type.BOOLEAN).withDescription("True after the requested operation completes.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Temporary access to file bytes. Clients follow url.
|
||||
*******************************************************************************/
|
||||
public record CollaborationDownloadV1(String url, String delivery) implements ToSchema
|
||||
{
|
||||
/***************************************************************************
|
||||
** Existing callers retain the storage URL contract.
|
||||
***************************************************************************/
|
||||
public CollaborationDownloadV1(String url)
|
||||
{
|
||||
this(url, "SIGNED_URL");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Temporary access to file bytes. Clients follow url.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("url", new Schema().withType(Type.STRING).withDescription("Temporary delivery URL; never persist it.").withNullable(true)),
|
||||
Map.entry("delivery", new Schema().withType(Type.STRING).withDescription("SIGNED_URL or AUTHENTICATED_FETCH. Authenticated fetch requires the trusted native API transport and a durable receipt check.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard QQQ error response.
|
||||
*******************************************************************************/
|
||||
public record CollaborationErrorV1(String error) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
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))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Target to star, unstar, or record as recently viewed.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTargetRequestV1(String targetType, String targetName, String recordId) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Target to star, unstar, or record as recently viewed.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("targetType", new Schema().withType(Type.STRING).withEnumValues(List.of("TABLE","APP","RECORD","PROCESS","REPORT","SAVED_VIEW")).withDescription("Required target kind.").withNullable(true)),
|
||||
Map.entry("targetName", new Schema().withType(Type.STRING).withDescription("Required metadata name.").withNullable(true)),
|
||||
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("Required for RECORD; omitted otherwise.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Move selected favorites first and optionally assign folders.
|
||||
*******************************************************************************/
|
||||
public record CollaborationFavoriteOrderRequestV1(List<Integer> ids, Map<String, String> folders) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Move selected favorites first and optionally assign folders.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("ids", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER)).withDescription("Required ordered favorite identifiers, with no duplicates.").withNullable(true)),
|
||||
Map.entry("folders", new Schema().withType(Type.OBJECT).withAdditionalProperties(new Schema().withType(Type.STRING).withNullable(true)).withDescription("Optional map from favorite identifier to folder; null values clear folders.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Change attachment display metadata.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAttachmentPatchRequestV1(String fileName, String description) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Change attachment display metadata.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("fileName", new Schema().withType(Type.STRING).withDescription("Optional new display filename; null leaves unchanged.").withNullable(true)),
|
||||
Map.entry("description", new Schema().withType(Type.STRING).withDescription("Optional description; empty clears, null leaves unchanged.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Multipart upload with exactly one file part named file.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAttachmentUploadRequestV1(String file, String description) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Multipart upload with exactly one file part named file.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("file", new Schema().withType(Type.STRING).withFormat("binary").withDescription("Required file content.").withNullable(true)),
|
||||
Map.entry("description", new Schema().withType(Type.STRING).withDescription("Optional description.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a tag or suggest one under the table policy.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTagCreateRequestV1(String table, String label, String color) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Create a tag or suggest one under the table policy.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("table", new Schema().withType(Type.STRING).withDescription("Required table metadata name.").withNullable(true)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING).withDescription("Required display label, 1 to 60 characters.").withNullable(true)),
|
||||
Map.entry("color", new Schema().withType(Type.STRING).withDescription("Optional display color.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Add and remove tag associations in one request.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTagApplyRequestV1(List<Integer> add, List<Integer> remove) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Add and remove tag associations in one request.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("add", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER)).withDescription("Tag identifiers to add; omitted means none.").withNullable(true)),
|
||||
Map.entry("remove", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER)).withDescription("Tag identifiers to remove; omitted means none.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Post a root comment or one-level reply.
|
||||
*******************************************************************************/
|
||||
public record CollaborationCommentPostRequestV1(String body, Integer parentCommentId, String fieldName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Post a root comment or one-level reply.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("body", new Schema().withType(Type.STRING).withDescription("Required body, 1 to 20000 characters; mentions use @[Name](user:id).").withNullable(true)),
|
||||
Map.entry("parentCommentId", new Schema().withType(Type.INTEGER).withDescription("Optional comment to reply to.").withNullable(true)),
|
||||
Map.entry("fieldName", new Schema().withType(Type.STRING).withDescription("Optional existing parent field name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace the caller's comment body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationCommentEditRequestV1(String body) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Replace the caller's comment body.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("body", new Schema().withType(Type.STRING).withDescription("Required replacement body, 1 to 20000 characters.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the caller's follow preferences.
|
||||
*******************************************************************************/
|
||||
public record CollaborationFollowRequestV1(Boolean notifyOnChange, Boolean notifyOnComment) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Configure the caller's follow preferences.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("notifyOnChange", new Schema().withType(Type.BOOLEAN).withDescription("Optional change notifications preference; omission retains current setting.").withNullable(true)),
|
||||
Map.entry("notifyOnComment", new Schema().withType(Type.BOOLEAN).withDescription("Optional comment notifications preference; omission retains current setting.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All named components referenced by the extension.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Schema> components()
|
||||
{
|
||||
Map<String, Schema> schemas = new TreeMap<>();
|
||||
schemas.put("CollaborationFavoriteV1", new CollaborationFavoriteV1(null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationAttachmentV1", new CollaborationAttachmentV1(null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTagV1", new CollaborationTagV1(null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationPersonV1", new CollaborationPersonV1(null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationMentionV1", new CollaborationMentionV1(null, null).toSchema());
|
||||
schemas.put("CollaborationMentionCandidateV1", new CollaborationMentionCandidateV1(null, null).toSchema());
|
||||
schemas.put("CollaborationCommentV1", new CollaborationCommentV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationCommentRevisionV1", new CollaborationCommentRevisionV1(null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationFollowV1", new CollaborationFollowV1(null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationFollowStateV1", new CollaborationFollowStateV1(null, null).toSchema());
|
||||
schemas.put("CollaborationTimelineDetailV1", new CollaborationTimelineDetailV1(null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTimelineItemV1", new CollaborationTimelineItemV1(null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationFavoriteListV1", new CollaborationFavoriteListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationRecentListV1", new CollaborationRecentListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationAttachmentListV1", new CollaborationAttachmentListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationTagListV1", new CollaborationTagListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationPersonListV1", new CollaborationPersonListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationMentionCandidateListV1", new CollaborationMentionCandidateListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationCommentPageV1", new CollaborationCommentPageV1(null, null).toSchema());
|
||||
schemas.put("CollaborationCommentHistoryV1", new CollaborationCommentHistoryV1(null, null).toSchema());
|
||||
schemas.put("CollaborationFollowerListV1", new CollaborationFollowerListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationTimelinePageV1", new CollaborationTimelinePageV1(null, null).toSchema());
|
||||
schemas.put("CollaborationMutationResultV1", new CollaborationMutationResultV1(null).toSchema());
|
||||
schemas.put("CollaborationDownloadV1", new CollaborationDownloadV1(null).toSchema());
|
||||
schemas.put("CollaborationErrorV1", new CollaborationErrorV1(null).toSchema());
|
||||
schemas.put("CollaborationTargetRequestV1", new CollaborationTargetRequestV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationFavoriteOrderRequestV1", new CollaborationFavoriteOrderRequestV1(null, null).toSchema());
|
||||
schemas.put("CollaborationAttachmentPatchRequestV1", new CollaborationAttachmentPatchRequestV1(null, null).toSchema());
|
||||
schemas.put("CollaborationAttachmentUploadRequestV1", new CollaborationAttachmentUploadRequestV1(null, null).toSchema());
|
||||
schemas.put("CollaborationTagCreateRequestV1", new CollaborationTagCreateRequestV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationTagApplyRequestV1", new CollaborationTagApplyRequestV1(null, null).toSchema());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* 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.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationAttachmentV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationCommentRevisionV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationCommentV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationFavoriteV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationFollowV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationMentionCandidateV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationMentionV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationPersonV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationTagV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationTimelineDetailV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationTimelineItemV1;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentEntry;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentEntry;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentRevision;
|
||||
import com.kingsrook.qbits.collaboration.comments.MentionCandidate;
|
||||
import com.kingsrook.qbits.collaboration.comments.MentionEntry;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowEntry;
|
||||
import com.kingsrook.qbits.collaboration.people.PeopleService;
|
||||
import com.kingsrook.qbits.collaboration.people.PersonEntry;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagEntry;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineDetail;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineItem;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map service results to the stable UI API wire types.
|
||||
*******************************************************************************/
|
||||
final class CollaborationViews
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationViews()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project favorite display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationFavoriteV1 favorite(FavoriteEntry value)
|
||||
{
|
||||
return (new CollaborationFavoriteV1(
|
||||
value.id(), value.targetType(), value.targetName(), value.recordId(), value.label(), value.folder(), value.sortOrder(), value.available(), value.href()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One display name per distinct user id, from a single directory query.
|
||||
*******************************************************************************/
|
||||
static Map<String, String> names(String... userIds) throws QException
|
||||
{
|
||||
return (new PeopleService().displayNames(Arrays.asList(userIds)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The display name for an id; null stays null, unknown ids show themselves.
|
||||
*******************************************************************************/
|
||||
private static String name(Map<String, String> names, String userId)
|
||||
{
|
||||
return (userId == null ? null : names.getOrDefault(userId, userId));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project attachment display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationAttachmentV1 attachment(AttachmentEntry value) throws QException
|
||||
{
|
||||
return (attachments(List.of(value)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project attachments, resolving uploader names in one batch.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationAttachmentV1> attachments(List<AttachmentEntry> values) throws QException
|
||||
{
|
||||
Map<String, String> names = names(values.stream().map(AttachmentEntry::createdByUserId).toArray(String[]::new));
|
||||
return (values.stream().map(value -> new CollaborationAttachmentV1(
|
||||
value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete(),
|
||||
name(names, value.createdByUserId()), value.canEdit())).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project tag display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationTagV1 tag(TagEntry value)
|
||||
{
|
||||
return (new CollaborationTagV1(
|
||||
value.id(), value.name(), value.label(), value.color(), value.tableName(), value.approved(), value.usageCount()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project person display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationPersonV1 person(PersonEntry value)
|
||||
{
|
||||
return (new CollaborationPersonV1(
|
||||
value.userId(), value.displayName(), value.email(), value.lastSeenAt()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project mention display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationMentionV1 mention(MentionEntry value)
|
||||
{
|
||||
return (new CollaborationMentionV1(
|
||||
value.userId(), value.visible()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project comment display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationCommentV1 comment(CommentEntry value)
|
||||
{
|
||||
return (new CollaborationCommentV1(
|
||||
value.id(), value.threadId(), value.parentCommentId(), value.body(), value.fieldName(), value.authorUserId(), value.authorDisplayName(), value.createdAt(), value.editedAt(), value.deleted(), value.resolved(), value.canEdit(), value.canDelete(), value.mentions().stream().map(CollaborationViews::mention).toList(), value.replies().stream().map(CollaborationViews::comment).toList()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project commentrevision display data.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationCommentRevisionV1> commentRevisions(List<CommentRevision> values) throws QException
|
||||
{
|
||||
Map<String, String> names = names(values.stream().map(CommentRevision::editedByUserId).toArray(String[]::new));
|
||||
return (values.stream().map(value -> new CollaborationCommentRevisionV1(
|
||||
value.id(), value.body(), value.editedAt(), value.editedByUserId(), name(names, value.editedByUserId()))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project follow display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationFollowV1 follow(FollowEntry value) throws QException
|
||||
{
|
||||
return (follows(List.of(value)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project followers, resolving names in one batch.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationFollowV1> follows(List<FollowEntry> values) throws QException
|
||||
{
|
||||
Map<String, String> names = names(values.stream().map(FollowEntry::userId).toArray(String[]::new));
|
||||
return (values.stream().map(value -> new CollaborationFollowV1(
|
||||
value.id(), value.userId(), value.reason(), value.notifyOnChange(), value.notifyOnComment(), value.mutedUntil(), name(names, value.userId()))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project timelinedetail display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationTimelineDetailV1 timelineDetail(TimelineDetail value)
|
||||
{
|
||||
return (new CollaborationTimelineDetailV1(
|
||||
value.fieldName(), value.oldValue(), value.newValue(), value.message()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project timelineitem display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationTimelineItemV1 timelineItem(TimelineItem value)
|
||||
{
|
||||
return (new CollaborationTimelineItemV1(
|
||||
value.kind(), value.id(), value.at(), value.actorUserId(), value.actorName(), value.summary(), value.details().stream().map(CollaborationViews::timelineDetail).toList(), value.refTable(), value.refId()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Represent access uncertainty explicitly instead of serializing Optional.
|
||||
*******************************************************************************/
|
||||
static CollaborationMentionCandidateV1 mentionCandidate(MentionCandidate value)
|
||||
{
|
||||
return (new CollaborationMentionCandidateV1(person(value.person()), value.canRead().map(Object::toString).orElse("unknown")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project Share public fields.
|
||||
*******************************************************************************/
|
||||
static CollaborationWorkSchemas.CollaborationShareV1 share(QRecord row) throws QException
|
||||
{
|
||||
return (shares(List.of(row)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project shares, resolving sender and recipient names in one batch.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationWorkSchemas.CollaborationShareV1> shares(List<QRecord> rows) throws QException
|
||||
{
|
||||
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("fromUserId"), row.getValueString("toUserId"))).toArray(String[]::new));
|
||||
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationShareV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
row.getValueString("fromUserId"), row.getValueString("toUserId"), row.getValueString("message"), row.getValueBoolean("recipientCouldRead"), row.getValueInstant("sentAt"),
|
||||
name(names, row.getValueString("fromUserId")), name(names, row.getValueString("toUserId")))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project AccessRequest public fields.
|
||||
*******************************************************************************/
|
||||
static CollaborationWorkSchemas.CollaborationAccessRequestV1 accessRequest(QRecord row) throws QException
|
||||
{
|
||||
return (accessRequests(List.of(row)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project access requests, resolving requester and handler names in one batch.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationWorkSchemas.CollaborationAccessRequestV1> accessRequests(List<QRecord> rows) throws QException
|
||||
{
|
||||
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("requesterUserId"), row.getValueString("handledByUserId"))).toArray(String[]::new));
|
||||
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationAccessRequestV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
row.getValueString("requesterUserId"), row.getValueString("message"), row.getValueString("status"), row.getValueInstant("handledAt"), row.getValueString("note"),
|
||||
name(names, row.getValueString("requesterUserId")), name(names, row.getValueString("handledByUserId")))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project Task public fields.
|
||||
*******************************************************************************/
|
||||
static CollaborationWorkSchemas.CollaborationTaskV1 task(QRecord row) throws QException
|
||||
{
|
||||
return (tasks(List.of(row)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project tasks, resolving assignee, creator and completer names in one batch.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationWorkSchemas.CollaborationTaskV1> tasks(List<QRecord> rows) throws QException
|
||||
{
|
||||
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("assigneeUserId"), row.getValueString("createdByUserId"),
|
||||
row.getValueString("completedByUserId"))).toArray(String[]::new));
|
||||
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationTaskV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
row.getValueString("title"), row.getValueString("notes"), row.getValueString("assigneeUserId"), row.getValueInstant("dueAt"), row.getValueInstant("remindAt"), row.getValueString("status"),
|
||||
row.getValueString("createdByUserId"), row.getValueInstant("completedAt"), row.getValueString("completedByUserId"),
|
||||
name(names, row.getValueString("assigneeUserId")), name(names, row.getValueString("createdByUserId")), name(names, row.getValueString("completedByUserId")))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project Assignment public fields.
|
||||
*******************************************************************************/
|
||||
static CollaborationWorkSchemas.CollaborationAssignmentV1 assignment(QRecord row) throws QException
|
||||
{
|
||||
Map<String, String> names = names(row.getValueString("ownerUserId"), row.getValueString("assigneeUserId"));
|
||||
return (new CollaborationWorkSchemas.CollaborationAssignmentV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
row.getValueString("ownerUserId"), row.getValueString("assigneeUserId"), name(names, row.getValueString("ownerUserId")), name(names, row.getValueString("assigneeUserId"))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed sharing, access request, task and work contracts.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationWorkSchemas
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationWorkSchemas()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned Link body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationLinkV1(String url, String label) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("url", new Schema().withType(Type.STRING).withDescription("url.").withNullable(true)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING).withDescription("label.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned ShareRecipient body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationShareRecipientV1(String userId, String displayName, Boolean canRead) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("userId", new Schema().withType(Type.STRING).withDescription("userId.").withNullable(true)),
|
||||
Map.entry("displayName", new Schema().withType(Type.STRING).withDescription("displayName.").withNullable(true)),
|
||||
Map.entry("canRead", new Schema().withType(Type.BOOLEAN).withDescription("canRead.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned SharePreview body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationSharePreviewV1(List<CollaborationShareRecipientV1> recipients) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("recipients", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationShareRecipientV1")).withDescription("recipients.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned Share body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationShareV1(Integer id, String tableName, String recordId, String fromUserId, String toUserId, String message, Boolean recipientCouldRead, Instant sentAt, String fromDisplayName, String toDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("id.").withNullable(true)),
|
||||
Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("tableName.").withNullable(true)),
|
||||
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("recordId.").withNullable(true)),
|
||||
Map.entry("fromUserId", new Schema().withType(Type.STRING).withDescription("fromUserId.").withNullable(true)),
|
||||
Map.entry("toUserId", new Schema().withType(Type.STRING).withDescription("toUserId.").withNullable(true)),
|
||||
Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)),
|
||||
Map.entry("recipientCouldRead", new Schema().withType(Type.BOOLEAN).withDescription("recipientCouldRead.").withNullable(true)),
|
||||
Map.entry("sentAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("sentAt.").withNullable(true)),
|
||||
Map.entry("fromDisplayName", new Schema().withType(Type.STRING).withDescription("Sender display name.").withNullable(true)),
|
||||
Map.entry("toDisplayName", new Schema().withType(Type.STRING).withDescription("Recipient display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned ShareList body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationShareListV1(List<CollaborationShareV1> items) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationShareV1")).withDescription("items.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned ShareRequest body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationShareRequestV1(List<String> toUserIds, String message) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("toUserIds", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.STRING)).withDescription("toUserIds.").withNullable(true)),
|
||||
Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned AccessRequest body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAccessRequestV1(Integer id, String tableName, String recordId, String requesterUserId, String message, String status, Instant handledAt, String note, String requesterDisplayName, String handledByDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("id.").withNullable(true)),
|
||||
Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("tableName.").withNullable(true)),
|
||||
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("recordId.").withNullable(true)),
|
||||
Map.entry("requesterUserId", new Schema().withType(Type.STRING).withDescription("requesterUserId.").withNullable(true)),
|
||||
Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)),
|
||||
Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true)),
|
||||
Map.entry("handledAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("handledAt.").withNullable(true)),
|
||||
Map.entry("note", new Schema().withType(Type.STRING).withDescription("note.").withNullable(true)),
|
||||
Map.entry("requesterDisplayName", new Schema().withType(Type.STRING).withDescription("Requester display name.").withNullable(true)),
|
||||
Map.entry("handledByDisplayName", new Schema().withType(Type.STRING).withDescription("Display name of the person who handled the request.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned AccessRequestCreateRequest body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAccessRequestCreateRequestV1(String tableName, String recordId, String message, Integer shareId) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("tableName.").withNullable(true)),
|
||||
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("recordId.").withNullable(true)),
|
||||
Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)),
|
||||
Map.entry("shareId", new Schema().withType(Type.INTEGER).withDescription("shareId.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned AccessRequestList body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAccessRequestListV1(List<CollaborationAccessRequestV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationAccessRequestV1")).withDescription("items.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("nextCursor.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned Task body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTaskV1(Integer id, String tableName, String recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt, String status, String createdByUserId, Instant completedAt, String completedByUserId, String assigneeDisplayName, String createdByDisplayName, String completedByDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("id.").withNullable(true)),
|
||||
Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("tableName.").withNullable(true)),
|
||||
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("recordId.").withNullable(true)),
|
||||
Map.entry("title", new Schema().withType(Type.STRING).withDescription("title.").withNullable(true)),
|
||||
Map.entry("notes", new Schema().withType(Type.STRING).withDescription("notes.").withNullable(true)),
|
||||
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true)),
|
||||
Map.entry("dueAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("dueAt.").withNullable(true)),
|
||||
Map.entry("remindAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("remindAt.").withNullable(true)),
|
||||
Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true)),
|
||||
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("createdByUserId.").withNullable(true)),
|
||||
Map.entry("completedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("completedAt.").withNullable(true)),
|
||||
Map.entry("completedByUserId", new Schema().withType(Type.STRING).withDescription("completedByUserId.").withNullable(true)),
|
||||
Map.entry("assigneeDisplayName", new Schema().withType(Type.STRING).withDescription("Assignee display name.").withNullable(true)),
|
||||
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Creator display name.").withNullable(true)),
|
||||
Map.entry("completedByDisplayName", new Schema().withType(Type.STRING).withDescription("Display name of the person who completed the task.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned TaskCreateRequest body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTaskCreateRequestV1(String title, String notes, String assigneeUserId, String dueAt, String remindAt) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("title", new Schema().withType(Type.STRING).withDescription("title.").withNullable(true)),
|
||||
Map.entry("notes", new Schema().withType(Type.STRING).withDescription("notes.").withNullable(true)),
|
||||
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true)),
|
||||
Map.entry("dueAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("dueAt.").withNullable(true)),
|
||||
Map.entry("remindAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("remindAt.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned TaskPatchRequest body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTaskPatchRequestV1(String title, String notes, String assigneeUserId, String dueAt, String remindAt, String status) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("title", new Schema().withType(Type.STRING).withDescription("title.").withNullable(true)),
|
||||
Map.entry("notes", new Schema().withType(Type.STRING).withDescription("notes.").withNullable(true)),
|
||||
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true)),
|
||||
Map.entry("dueAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("dueAt.").withNullable(true)),
|
||||
Map.entry("remindAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("remindAt.").withNullable(true)),
|
||||
Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned TaskList body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTaskListV1(List<CollaborationTaskV1> items, String nextCursor) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationTaskV1")).withDescription("items.").withNullable(true)),
|
||||
Map.entry("nextCursor", new Schema().withType(Type.STRING).withDescription("nextCursor.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned AssignmentRequest body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAssignmentRequestV1(String ownerUserId, String assigneeUserId) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("ownerUserId", new Schema().withType(Type.STRING).withDescription("ownerUserId.").withNullable(true)),
|
||||
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned Assignment body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAssignmentV1(Integer id, String tableName, String recordId, String ownerUserId, String assigneeUserId, String ownerDisplayName, String assigneeDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("id.").withNullable(true)),
|
||||
Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("tableName.").withNullable(true)),
|
||||
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("recordId.").withNullable(true)),
|
||||
Map.entry("ownerUserId", new Schema().withType(Type.STRING).withDescription("ownerUserId.").withNullable(true)),
|
||||
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true)),
|
||||
Map.entry("ownerDisplayName", new Schema().withType(Type.STRING).withDescription("Owner display name.").withNullable(true)),
|
||||
Map.entry("assigneeDisplayName", new Schema().withType(Type.STRING).withDescription("Assignee display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned MyWork body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationMyWorkV1(Long openTaskCount, List<CollaborationTaskV1> tasks, Map<String, Long> assignedRecordsByTable, Long unreadMentionCount) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("openTaskCount", new Schema().withType(Type.INTEGER).withDescription("openTaskCount.").withNullable(true)),
|
||||
Map.entry("tasks", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationTaskV1")).withDescription("tasks.").withNullable(true)),
|
||||
Map.entry("assignedRecordsByTable", new Schema().withType(Type.OBJECT).withAdditionalProperties(new Schema().withType(Type.INTEGER)).withDescription("assignedRecordsByTable.").withNullable(true)),
|
||||
Map.entry("unreadMentionCount", new Schema().withType(Type.INTEGER).withDescription("unreadMentionCount.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Named components used by these endpoints.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Schema> components()
|
||||
{
|
||||
Map<String, Schema> schemas = new TreeMap<>();
|
||||
schemas.put("CollaborationLinkV1", new CollaborationLinkV1(null, null).toSchema());
|
||||
schemas.put("CollaborationShareRecipientV1", new CollaborationShareRecipientV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationSharePreviewV1", new CollaborationSharePreviewV1(null).toSchema());
|
||||
schemas.put("CollaborationShareV1", new CollaborationShareV1(null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationShareListV1", new CollaborationShareListV1(null).toSchema());
|
||||
schemas.put("CollaborationShareRequestV1", new CollaborationShareRequestV1(null, null).toSchema());
|
||||
schemas.put("CollaborationAccessRequestV1", new CollaborationAccessRequestV1(null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationAccessRequestCreateRequestV1", new CollaborationAccessRequestCreateRequestV1(null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationAccessRequestListV1", new CollaborationAccessRequestListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationTaskV1", new CollaborationTaskV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTaskCreateRequestV1", new CollaborationTaskCreateRequestV1(null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTaskPatchRequestV1", new CollaborationTaskPatchRequestV1(null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTaskListV1", new CollaborationTaskListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationAssignmentRequestV1", new CollaborationAssignmentRequestV1(null, null).toSchema());
|
||||
schemas.put("CollaborationAssignmentV1", new CollaborationAssignmentV1(null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationMyWorkV1", new CollaborationMyWorkV1(null, null, null, null).toSchema());
|
||||
return (schemas);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationCommentHistoryV1;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentsService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read comment edit history.
|
||||
*******************************************************************************/
|
||||
public class CommentHistorySpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/comments/{id}/history", "Read comment edit history"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationCommentHistoryV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = page(input, new CommentsService().history(id(input)));
|
||||
return (new CollaborationCommentHistoryV1(CollaborationViews.commentRevisions(page.items()), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
/*******************************************************************************
|
||||
** CreateAccessRequest endpoint.
|
||||
*******************************************************************************/
|
||||
public class CreateAccessRequestSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/access-requests", "Request access to a record"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationAccessRequestV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the request.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationAccessRequestCreateRequestV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the service as the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationWorkSchemas.CollaborationAccessRequestCreateRequestV1.class);
|
||||
if(body.shareId() != null && body.shareId() < 1)
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("shareId must be positive"));
|
||||
}
|
||||
return (CollaborationViews.accessRequest(new com.kingsrook.qbits.collaboration.sharing.AccessRequestService()
|
||||
.request(required(body.tableName(), "tableName"), required(body.recordId(), "recordId"), body.message(), body.shareId())));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
/*******************************************************************************
|
||||
** CreateShares endpoint.
|
||||
*******************************************************************************/
|
||||
public class CreateSharesSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/records/{table}/{pk}/shares", "Share a record"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationShareListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the request.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationShareRequestV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the service as the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationWorkSchemas.CollaborationShareRequestV1.class);
|
||||
return (new CollaborationWorkSchemas.CollaborationShareListV1(CollaborationViews.shares(new com.kingsrook.qbits.collaboration.sharing.SharingService()
|
||||
.share(table(input), pk(input), recipients(body.toUserIds()), body.message()))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationTagCreateRequestV1;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagsService;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create or suggest a tag.
|
||||
*******************************************************************************/
|
||||
public class CreateTagSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/tags", "Create or suggest a tag"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationTagV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationTagCreateRequestV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationTagCreateRequestV1.class);
|
||||
return (CollaborationViews.tag(new TagsService().create(required(body.table(), "table"), body.label(), body.color())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
/*******************************************************************************
|
||||
** CreateTask endpoint.
|
||||
*******************************************************************************/
|
||||
public class CreateTaskSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/records/{table}/{pk}/tasks", "Create a task"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationTaskV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the request.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationTaskCreateRequestV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the service as the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationWorkSchemas.CollaborationTaskCreateRequestV1.class);
|
||||
return (CollaborationViews.task(new com.kingsrook.qbits.collaboration.tasks.TasksService().create(table(input), pk(input),
|
||||
required(body.title(), "title"), body.notes(), body.assigneeUserId(), instant(body.dueAt()), instant(body.remindAt()))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.attachments.AttachmentsService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete an attachment.
|
||||
*******************************************************************************/
|
||||
public class DeleteAttachmentSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.DELETE, "/attachments/{id}", "Delete an attachment"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationMutationResultV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
new AttachmentsService().delete(List.of(id(input)));
|
||||
return (success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.comments.CommentsService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a comment thread.
|
||||
*******************************************************************************/
|
||||
public class DeleteCommentSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.DELETE, "/comments/{id}", "Delete a comment thread"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationMutationResultV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
new CommentsService().delete(id(input));
|
||||
return (success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationFollowStateV1;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unfollow a record.
|
||||
*******************************************************************************/
|
||||
public class DeleteFollowSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.DELETE, "/records/{table}/{pk}/follow", "Unfollow a record"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationFollowStateV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
new FollowService().unfollow(table(input), pk(input));
|
||||
return (new CollaborationFollowStateV1(false, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Leave presence with fresh parent access checks.
|
||||
*******************************************************************************/
|
||||
public class DeletePresenceSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the extension operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.DELETE, "/presence", "Leave presence"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit query parameters for polling.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationMutationResultV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare a typed JSON request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationPresenceLeaveV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services derive identity from QContext and enforce visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
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(), body.sequence());
|
||||
return (success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationDownloadV1;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentsService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get a temporary download URL; clients follow url.
|
||||
*******************************************************************************/
|
||||
public class DownloadAttachmentSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/attachments/{id}/download", "Get a temporary download URL; clients follow url"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationDownloadV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var delivery = new AttachmentsService().downloadDelivery(id(input));
|
||||
return (new CollaborationDownloadV1(delivery.url().toExternalForm(), delivery.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationCommentEditRequestV1;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentsService;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Edit the caller's comment.
|
||||
*******************************************************************************/
|
||||
public class EditCommentSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.PATCH, "/comments/{id}", "Edit the caller's comment"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationCommentV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("CollaborationCommentEditRequestV1"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationCommentEditRequestV1.class);
|
||||
return (CollaborationViews.comment(new CommentsService().edit(id(input), body.body())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationFollowStateV1;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the caller's follow state.
|
||||
*******************************************************************************/
|
||||
public class GetFollowSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/records/{table}/{pk}/follow", "Get the caller's follow state"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationFollowStateV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var state = new FollowService().state(table(input), pk(input));
|
||||
return (new CollaborationFollowStateV1(state.isPresent(), state.isPresent() ? CollaborationViews.follow(state.get()) : null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** GetMyWork endpoint.
|
||||
*******************************************************************************/
|
||||
public class GetMyWorkSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/my-work", "Get my work summary"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationMyWorkV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the service as the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var summary = new com.kingsrook.qbits.collaboration.mywork.MyWorkService().summary();
|
||||
return (new CollaborationWorkSchemas.CollaborationMyWorkV1(summary.openTaskCount(), CollaborationViews.tasks(summary.tasks()),
|
||||
summary.assignedRecordsByTable(), summary.unreadMentionCount()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.people.PeopleService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get an active directory person.
|
||||
*******************************************************************************/
|
||||
public class GetPersonSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/people/{userId}", "Get an active directory person"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationPersonV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (CollaborationViews.person(new PeopleService().get(input.getPathParam("userId"))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Get presence snapshot with fresh parent access checks.
|
||||
*******************************************************************************/
|
||||
public class GetPresenceSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the extension operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/presence", "Get presence snapshot"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit query parameters for polling.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("table!", "pk"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationPresenceSnapshotV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services derive identity from QContext and enforce visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
String table = required(input.getQueryParam("table"), "table");
|
||||
String pk = input.getQueryParam("pk");
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, new PresenceService().snapshot(table, pk)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/*******************************************************************************
|
||||
** GetRecordLink endpoint.
|
||||
*******************************************************************************/
|
||||
public class GetRecordLinkSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/records/{table}/{pk}/link", "Get a record link"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationLinkV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the service as the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var link = new com.kingsrook.qbits.collaboration.sharing.SharingService().link(table(input), pk(input));
|
||||
return (new CollaborationWorkSchemas.CollaborationLinkV1(link.url(), link.label()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationTimelinePageV1;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Page the merged record timeline.
|
||||
*******************************************************************************/
|
||||
public class GetTimelineSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/records/{table}/{pk}/timeline", "Page the merged record timeline"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("before", "types", "limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationTimelinePageV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = new TimelineService().timeline(table(input), pk(input), timelineCursor(input), limit(input), types(input));
|
||||
return (new CollaborationTimelinePageV1(page.items().stream().map(CollaborationViews::timelineItem).toList(), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationAttachmentListV1;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentsService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** List record attachments.
|
||||
*******************************************************************************/
|
||||
public class ListAttachmentsSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/records/{table}/{pk}/attachments", "List record attachments"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationAttachmentListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
int limit = limit(input);
|
||||
var position = pagePosition(input);
|
||||
var page = new AttachmentsService().listPage(table(input), pk(input), position.offset(), limit);
|
||||
String next = page.hasMore() ? position.cursor((long) position.offset() + page.items().size()) : null;
|
||||
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), next));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationCommentPageV1;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentsService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Page record comment threads.
|
||||
*******************************************************************************/
|
||||
public class ListCommentsSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/records/{table}/{pk}/comments", "Page record comment threads"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationCommentPageV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = new CommentsService().list(table(input), pk(input), input.getQueryParam("cursor"), limit(input));
|
||||
return (new CollaborationCommentPageV1(page.threads().stream().map(CollaborationViews::comment).toList(), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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 java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationFavoriteListV1;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** List personal favorites.
|
||||
*******************************************************************************/
|
||||
public class ListFavoritesSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The extension's first spec publishes the shared schema catalog exactly once.
|
||||
** QQQ collects components across specs and warns on every duplicate name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return (CollaborationSchemas.components());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/favorites", "List personal favorites"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationFavoriteListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = page(input, FavoritesService.list());
|
||||
return (new CollaborationFavoriteListV1(page.items().stream().map(CollaborationViews::favorite).toList(), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationFollowerListV1;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** List record followers.
|
||||
*******************************************************************************/
|
||||
public class ListFollowersSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/records/{table}/{pk}/followers", "List record followers"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationFollowerListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = page(input, new FollowService().followers(table(input), pk(input)));
|
||||
return (new CollaborationFollowerListV1(CollaborationViews.follows(page.items()), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** ListMyAccessRequests endpoint.
|
||||
*******************************************************************************/
|
||||
public class ListMyAccessRequestsSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/access-requests/mine", "List my access requests"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationAccessRequestListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the service as the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var values = new com.kingsrook.qbits.collaboration.sharing.AccessRequestService().mine();
|
||||
var page = page(input, values);
|
||||
return (new CollaborationWorkSchemas.CollaborationAccessRequestListV1(CollaborationViews.accessRequests(page.items()), page.nextCursor()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/*******************************************************************************
|
||||
** ListMyTasks endpoint.
|
||||
*******************************************************************************/
|
||||
public class ListMyTasksSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/tasks/mine", "List my tasks"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("status", "limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationTaskListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the service as the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = new com.kingsrook.qbits.collaboration.tasks.TasksService().mine(input.getQueryParam("status"), limit(input), input.getQueryParam("cursor"));
|
||||
return (new CollaborationWorkSchemas.CollaborationTaskListV1(CollaborationViews.tasks(page.tasks()), page.nextCursor()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationPersonListV1;
|
||||
import com.kingsrook.qbits.collaboration.people.PeopleService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Search the active people directory.
|
||||
*******************************************************************************/
|
||||
public class ListPeopleSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/people", "Search the active people directory"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("q", "limit"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationPersonListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
new PeopleService().touch();
|
||||
return (new CollaborationPersonListV1(new PeopleService().search(input.getQueryParam("q"), limit(input)).stream().map(CollaborationViews::person).toList(), null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationRecentListV1;
|
||||
import com.kingsrook.qbits.collaboration.recents.RecentsService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** List recent targets.
|
||||
*******************************************************************************/
|
||||
public class ListRecentsSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/recents", "List recent targets"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationRecentListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = page(input, RecentsService.list(50));
|
||||
return (new CollaborationRecentListV1(page.items().stream().map(CollaborationViews::favorite).toList(), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationTagListV1;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagsService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** List applied record tags.
|
||||
*******************************************************************************/
|
||||
public class ListRecordTagsSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/records/{table}/{pk}/tags", "List applied record tags"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationTagListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = page(input, new TagsService().tagsFor(table(input), pk(input)));
|
||||
return (new CollaborationTagListV1(page.items().stream().map(CollaborationViews::tag).toList(), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationTagListV1;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagsService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Search table tag vocabulary.
|
||||
*******************************************************************************/
|
||||
public class ListTagsSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/tags", "Search table tag vocabulary"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("table!", "q", "limit"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationTagListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new CollaborationTagListV1(new TagsService().vocabulary(required(input.getQueryParam("table"), "table"), input.getQueryParam("q"), limit(input)).stream().map(CollaborationViews::tag).toList(), null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** ListTasks endpoint.
|
||||
*******************************************************************************/
|
||||
public class ListTasksSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/records/{table}/{pk}/tasks", "List record tasks"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("includeDone", "limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationTaskListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the service as the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
String includeDone = input.getQueryParam("includeDone");
|
||||
if(includeDone != null && !List.of("true", "false").contains(includeDone))
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("includeDone must be true or false"));
|
||||
}
|
||||
var page = page(input, new com.kingsrook.qbits.collaboration.tasks.TasksService().list(table(input), pk(input), Boolean.valueOf(includeDone)));
|
||||
return (new CollaborationWorkSchemas.CollaborationTaskListV1(CollaborationViews.tasks(page.items()), page.nextCursor()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,74 @@
|
||||
/*
|
||||
* 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.api.CollaborationSchemas.CollaborationMentionCandidateListV1;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentsService;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find people to mention on a record.
|
||||
*******************************************************************************/
|
||||
public class MentionCandidatesSpec extends CollaborationJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/mentions/candidates", "Find people to mention on a record"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query inputs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("table!", "pk!", "q", "limit", "cursor"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the versioned response.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("CollaborationMentionCandidateListV1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the service with the authenticated caller's context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = page(input, new CommentsService().mentionCandidates(required(input.getQueryParam("table"), "table"), required(input.getQueryParam("pk"), "pk"), input.getQueryParam("q")));
|
||||
return (new CollaborationMentionCandidateListV1(page.items().stream().map(CollaborationViews::mentionCandidate).toList(), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user