Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 e4f92192c6 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:04:54 -05:00
james.maes 95506ed706 Merge branch 'feature/presence-custom-fields' into feature/demo-dev-server 2026-10-13 22:19:51 -05:00
james.maes bc992fd5f8 Merge branch 'feature/ui-api-endpoints' into feature/presence-custom-fields
# Conflicts:
#	src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java
2026-10-13 22:19:42 -05:00
james.maes 5f56bf2853 Merge remote-tracking branch 'origin/develop' into feature/ui-api-endpoints 2026-10-13 22:19:14 -05:00
james.maesandClaude Opus 5.5 a9baecd293 build: QQQ 4.1.0-RC.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:19:13 -05:00
james.maesandClaude Opus 5.5 7b7c921079 test: collaboration demo dev server with seeded Sales data, plus a SELECT custom-field refresh fix
CollaborationDevServer serves the Next dashboard over an in-memory
instance (people, orders, every record feature on, seeded comments,
files, tags, custom fields, tasks, shares, audit history) with mock users
alice, bob and admin. CustomFieldsProvider now registers generated SELECT
possible value sources on refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:04:03 -05:00
james.maesandClaude Opus 5.5 08498dd1db feat: custom field designer (JSONB + EAV fallback, managed indexes) and presence
collaboration_field_definition (with DataClassification) and
collaboration_field_value; CustomFieldsProvider contributes ACTIVE fields
through the runtime overlay with JsonPathStorage (Postgres, memory) or a
read-only EAV virtual field elsewhere; Publish/Archive/Rebuild index
actions; the collaborationBuildFieldIndexes job creates expression
indexes CONCURRENTLY (or records the DDL when unmanaged). Presence:
PresenceRegistry replicated over the realtime bus with a 45 s TTL and a
cap, presence.changed events, and POST/DELETE/GET /presence endpoints;
an admin Who's here widget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:09:49 -05:00
james.maesandClaude Opus 5.5 77d005efd3 feat: adopt core collaboration contracts; sharing, tasks, assignment and My work endpoints
Feature settings drive core RecordFeaturesMetaData through
RecordFeatureOverrideProviderInterface; feature permissions come from core
(policy-editor catalog); extension tables declare RecordReferenceTableMetaData
so core enforces parent read access, write rules and mirrored locks; core
cleanup, record-access and summary SPIs are implemented; tags become the
collaborationTags virtual field with a QSubQuery criteria expander (filter,
quick search, #tag) contributed through the runtime overlay; qbit events
publish on the realtime bus. Removes the stand-ins (OtherUserAccess,
UserSessionSupplier, ParentTableRestriction, TagFilterTranslator). Adds
the link, share, access request, task, assignment and My work endpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:32:03 -05:00
james.maes 05b7fcdc08 Merge pull request #12 from QRun-IO/feature/contingent-fields
feat: contingent possible values (thread, scope, field anchor)
2026-10-13 20:22:27 -05:00
james.maesandClaude Opus 5.5 9ba6e41377 feat: contingent possible values for comment parent, feature scope name and comment field anchor
parentCommentId lists only its thread (threadId or id = ${input.threadId});
feature settings' scopeName is listed by scopeType (apps or eligible
tables) through FeatureScopeNamePossibleValueProvider; a comment's
fieldName lists the visible fields of its tableName through the reusable
RecordFieldNamePossibleValueProvider. TODOs mark the core dependsOn
switch. One test per pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:18:24 -05:00
james.maes 38a95aeee0 Merge pull request #11 from QRun-IO/fix/object-storage-api-only
fix(attachments): use only qbit-object-storage's api package
2026-10-13 20:01:33 -05:00
james.maes ab6e8442be Merge remote-tracking branch 'origin/develop' into feature/ui-api-endpoints
# Conflicts:
#	CHANGELOG.md
#	README.md
2026-10-13 20:01:31 -05:00
james.maesandClaude Opus 5.5 88bff675e9 fix(attachments): use only qbit-object-storage's api package
Stop reading the bucket cap through StorageBucket.config() (the non-api
StorageBucketConfig, flagged by the qbit-bom guardrails, G1-arch).
Collaboration enforces its per-table cap; object storage still enforces
the bucket cap at put time. An api accessor is requested upstream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:58:41 -05:00
james.maes 0b19f956a2 Merge pull request #9 from QRun-IO/feature/sharing-tasks
feat: sharing with access requests, tasks, owner/assignee and My work
2026-10-13 19:09:31 -05:00
james.maesandClaude Opus 5.5 22c9ec22fa feat: sharing with access requests, tasks with reminders, owner/assignee and My work
collaboration_share and collaboration_access_request (requesters see only
table and id; non-readers get a notification without record data);
collaboration_task with reminders and overdue notices, Mark done/Reopen;
collaboration_record_assignment with Assign to / Take ownership bulk
actions; MyWorkService summary (open tasks, records assigned, unread
mentions); home cards; parent cleanup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:31:36 -05:00
james.maes 003384abd4 Merge pull request #5 from QRun-IO/feature/tags
feat: tags with approval modes and bulk actions
2026-10-13 17:40:50 -05:00
james.maesandClaude Opus 5.5 be1982a0b1 feat: collaboration UI API extension (/qqq/v1/qbit/collaboration)
31 spec'd operations over favorites, recents, attachments (multipart
upload; download/preview return a short-lived url), tags, comments,
mentions, follow, timeline and people, with Collaboration* typed schemas,
the committed extension fragment openapi/qqq-v1/collaboration.yaml and a
contract test. Built against QQQ 4.1.0-SNAPSHOT for the extension point
(QRun-IO/qqq#1128); moves to 4.1.0-RC.6 when released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:28:53 -05:00
james.maesandClaude Opus 5.5 696f4f48ce feat: tags with per-table vocabularies, approval modes and bulk tag actions
collaboration_tag and collaboration_record_tag; FREE or APPROVED_ONLY per
table from feature settings; normalized names, suggestions, usage counts;
Approve, Merge and Delete tags record actions; Add tags / Remove tags bulk
actions for configured host tables; TagFilterTranslator for the filter
operators, to be wrapped in core's criteria expander once QRun-IO/qqq#1140
is released; tag activity on the timeline; the home Tags card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:03:30 -05:00
james.maes 76e4944919 Merge pull request #4 from QRun-IO/feature/comments-follow-timeline
feat: comments with mentions, follow and activity timeline
2026-10-13 16:44:35 -05:00
james.maesandClaude Opus 5.5 711baede51 feat: comments with mentions, follow/watch and the record activity timeline
collaboration_comment (+ revisions, mentions), collaboration_follow,
collaboration_activity and collaboration_follow_digest tables; comment
threads with markdown bodies, @mentions checked for the recipient's
access, edit history, soft delete and resolve; follow with defaults,
bulk Follow/Unfollow actions and batched change notifications; a
timeline merging audit and activity; a people directory. Notifications go
through a CollaborationNotifier (no-op by default) until the
QAlerts.notify adapter (QRun-IO/qqq#1125) is available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:42:26 -05:00
james.maes 4898e78c5d Merge pull request #3 from QRun-IO/feature/attachments
feat: record attachments through qbit-object-storage
2026-10-13 16:15:24 -05:00
james.maes 1e750b9dc6 Merge pull request #2 from QRun-IO/feature/favorites-recents
feat: favorites and recently viewed
2026-10-13 15:57:29 -05:00
172 changed files with 27750 additions and 254 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
+69 -2
View File
@@ -6,10 +6,78 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Added
- Feature Settings table action **Create rows from configuration**: writes the code configuration out as editable `collaboration_feature_setting` rows (insert-only, only when the table is empty, no change in behavior).
- Setup card on the Collaboration home: each record feature, whether it is on, for which tables, and where that comes from.
- Host-neutral QBIT help bundle covering tables, fields, sections, built-in actions, the app, widgets and possible-value sources; core registration and `HelpBundleAssertions` coverage replace the temporary help loader.
- `CollaborationTablesGuardrailTest` and `FeatureSettingsDefaultsTest`.
### Changed
- 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
- 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 +86,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.
+596 -17
View File
@@ -1,16 +1,127 @@
# 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.
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.
- QQQ `4.1.0-RC.7` from Maven Central; middleware Javalin is a compile dependency.
- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use the memory backend.
- Attachments require `qbit-object-storage:0.1.0-RC.2` (a pinned dependency), installed with a PRIVATE bucket alias matching `attachmentsBucketName`. Configure its connection and credentials in the host; this qbit does not provision buckets.
- 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 QQQ `4.1.0-RC.7`.
## Followers and secured data
The instance registers a core `RecordFollowersProviderInterface` backed by
`collaboration_follow`, so alerts and other qbits can use `RecordFollowers.get`.
CHANGE and ALERT select `notifyOnChange`; COMMENT selects `notifyOnComment`.
Future mutes are excluded, and disabling FOLLOW returns no subscribers. The
provider reads only the requested table/record as the system. Consumers must
check each recipient's record access before sending data.
Favorites and recents cache a record label only when every label field is FULL
for the current session; otherwise the stored label is null. Lists continue to
re-read labels as the viewer. Comment and share notification titles resolve
labels in each recipient's session and fall back to the table label and id
(for example, `Order #1042`) when label access is restricted or unavailable.
Timeline audit reads use core's enforced query boundary: MASKED values are
masked, HIDDEN details are omitted, and restricted audit messages are cleared.
Comment bodies remain user content; realtime field-name filtering is handled by core.
## Demo server
`CollaborationDevServer` is a disposable, memory-backed Sales CRM for trying the
Next dashboard and taking screenshots. It needs Java 21 and QQQ `4.1.0-RC.7` from Maven Central. No database, Docker or MinIO is needed. It binds
to `127.0.0.1`; stopping the JVM discards edits and uploaded attachments.
Build the Next static export in `qqq-frontend-next`:
```bash
QQQ_NEXT_OUTPUT=export pnpm build
```
In PowerShell, use `$env:QQQ_NEXT_OUTPUT = 'export'; pnpm build` for that command.
Then, from this repository, prepare the test classpath and place the export under
a classpath directory named `next-dashboard`, following StorageDevServer's layout:
```powershell
.\mvnw.cmd -o -q test-compile dependency:build-classpath '-Dmdep.includeScope=test' '-Dmdep.outputFile=target/demo-classpath.txt'
New-Item -ItemType Directory -Force target/demo-frontend/next-dashboard | Out-Null
Copy-Item -Path ../qqq-frontend-next/out/* -Destination target/demo-frontend/next-dashboard -Recurse -Force
$demoDeps = (Get-Content target/demo-classpath.txt -Raw).Trim()
$demoClasspath = "target/test-classes;target/classes;$demoDeps;target/demo-frontend"
java '-Dqqq.javalin.frontend=next' '-Dcollab.dev.port=8098' '-Dcollab.dev.user=alice' -cp $demoClasspath com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer
```
The equivalent Java command is
`java -Dqqq.javalin.frontend=next -Dcollab.dev.port=8098 -Dcollab.dev.user=alice -cp "target/test-classes;target/classes;DEPS;EXPORT" com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer`.
`DEPS` is the generated classpath file's contents; `EXPORT` is the folder **containing**
`next-dashboard/`, not the export itself. On macOS/Linux use `./mvnw` and `:` instead
of `;` between classpath entries.
Open [the demo](http://localhost:8098/?createMockSession=true), then **Sales → People**
or **Sales orders**. [Person 1](http://localhost:8098/app/person/1) and
[order 1](http://localhost:8098/app/sales_order/1) have the richest collaboration panels.
Pick the user per browser: add `user=` to the page load, for example
`http://localhost:8098/?createMockSession=true&user=bob`. The server remembers the choice in a
`collab_dev_user` cookie, so **open a second browser with `?user=bob` to see presence** (your
first browser keeps its own user; browsers on the same profile share the cookie). Without a
choice, `-Dcollab.dev.user=alice|bob|admin` (one value, not the literal list) is the default.
The MOCK session customizer supplies stable identities, display names (the sidebar shows
Alice Morgan, not Anonymous) and real permissions, also used by recipient access checks.
Shared record links are absolute (`frontendBaseUrl` is `http://localhost:<port>`):
| User | Permissions |
| --- | --- |
| `alice` — Alice Morgan (default) | Full Sales access and comment, attach, tag, share, task and assign permissions |
| `bob` — Bob Chen | People read/comment; no Sales order access |
| `admin` — Demo Administrator | Full Sales access plus `collaboration.admin`, vocabulary/field management and Admin → Collaboration |
Both tables enable every record feature. People use FREE tags and host owner/assignee
fields; orders use APPROVED_ONLY tags. The seed contains 25 fictional contacts at
10 companies, 40 orders with deterministic amounts/dates, eight approved tags,
two ACTIVE custom fields (Preferred contact; searchable Purchase order no.), six
comments with three mentions and a resolved thread, three generated PNG/PDF attachments,
follows, four tasks (one overdue and one completed), two order assignments, two shares, Bob's open order-access
request, and six favorites/recents for Alice. Ordinary record updates create FIELD
audit history. Task deadlines are relative to startup so the overdue example stays useful.
Attachment previews/downloads use real in-memory bytes and expiring local URLs.
Notifications are recorded in memory; they do not send email. Presence is live and
ephemeral: the current browser supplies its heartbeat; there are no fake persistent viewers.
Two current permission edge cases remain visible in this snapshot: Bob can start a
comment thread, but replying to another author's thread fails its activity update;
an access request carrying a share ID for an unreadable parent returns 404. The seed
uses Bob's own thread and a direct access request without a share ID.
The test builds and seeds without frontend assets, validates the instance, then checks
the version/collaboration endpoints, downloads and Bob's restricted access on a free port:
```powershell
.\mvnw.cmd -o -q verify
```
## Install
@@ -39,6 +150,15 @@ new CollaborationQBitProducer()
Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation after all producers.
## Starter defaults and setup
Collaboration ships no SQL seed. The one configuration table, `collaboration_feature_setting`, depends on the host's configuration (`withDefaultFeatures`, `withTableFeatures`, the default tag mode, features declared on apps and tables), so its rows are computed at run time:
- Open Feature Settings and run the table action **Create rows from configuration** (collaboration administrators). It writes one Instance row per feature, plus an App or Table row wherever the code declares something at that scope. The rows say exactly what the code already says, so nothing changes; `FeatureSettingsDefaultsTest` proves every table resolves identically with and without them.
- It only creates rows when the table is empty, so it never touches an administrator's rows. Rows are applied after all code configuration. An Instance row overrides what code declares on any app or table that has no row of its own, so once rows exist, change features in the rows, not in code.
- The **Setup** card on the Collaboration home shows each feature, whether it is on, for which tables, and whether that comes from Feature Settings rows or the configuration.
- Every collaboration table has help (shown when it is empty) that says how its rows get created. The other tables hold user content or runtime data and start empty by design.
## Tables
| Table | Purpose |
@@ -47,19 +167,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 +218,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 +246,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 +354,390 @@ 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 but not selectable: the overlay has no virtual-field post-query hook and existing host customizers must be preserved.
## UI API
The producer registers extension `collaboration` version `1.0.0` at
`/qqq/v1/qbit/collaboration`. It uses the signed-in user's session and existing
services. Disable registration with `new CollaborationQBitConfig().withUiApi(false)`.
Presence, shares, tasks, my-work, direct upload and thumbnail routes are not part
of this extension version.
All successes return HTTP 200. All 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 identical `404 {"error":"Not found"}`.
Missing write permissions return 403; invalid input returns 400 with an `error`
message through QQQ's exception mapper. Provider/backend failures remain server
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 stays at unreleased **1.0.0**; contract tooling accepts these additions without a bump.
`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=...` | Omit pk for a table snapshot |
Session identity supplies userId and displayName. Every call rechecks table READ and record
GetAction visibility. Disabled features and denied/missing parents return the same 404. A
refused renewal removes the caller's previous lease. Connection ids are namespaced by user;
they need not be active SSE connections, allowing polling clients to participate.
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.
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 counts
per table, and core record-feature summaries include the presence snapshot.
+8
View File
@@ -0,0 +1,8 @@
# Upgrading
## Unreleased
No schema change and nothing required of hosts.
- New table action **Create rows from configuration** on Feature Settings. Run it once if you want the effective feature settings visible and editable as rows. It changes no behavior and does nothing once the table has any row. Note that after rows exist they win over later changes to `withDefaultFeatures` or `withTableFeatures` in code, so change those settings in the rows from then on.
- New **Setup** widget first on the Collaboration home, and table help text. Hosts that override help on a table keep their own.
+9
View File
@@ -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.
+139 -5
View File
@@ -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.
+44 -4
View File
@@ -22,12 +22,14 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<!-- qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -67,15 +69,36 @@
</scm>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-object-storage</artifactId>
<version>0.1.0-RC.2</version>
<version>0.1.0-RC.4</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
</dependency>
<!-- optional: email through qbit-email when it is produced in the host instance (EmailCollaborationNotifier) -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<optional>true</optional>
<exclusions>
<exclusion>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -90,6 +113,23 @@
</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>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -173,14 +213,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.5 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.7 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>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -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,67 @@ 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 Function<String, String> recipientEmailResolver = null;
private QCodeReference userSessionProvider = null;
private String defaultTagMode = "FREE";
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 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);
}
/*******************************************************************************
@@ -244,6 +301,15 @@ public class CollaborationQBitConfig implements QBitConfig
{
assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors);
}
assertCondition(List.of("FREE", "APPROVED_ONLY").contains(defaultTagMode == null ? "" : defaultTagMode), "defaultTagMode must be FREE or APPROVED_ONLY", errors);
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 +422,256 @@ 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);
}
/*******************************************************************************
** 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;
}
}
@@ -29,6 +29,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;
@@ -128,7 +129,30 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
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(FeatureSettingsDefaultsProcess.process());
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
{
output.add(source);
@@ -140,15 +164,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,104 @@
/*
* 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.
*******************************************************************************/
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();
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;
}
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,203 @@
/*
* 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.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.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
{
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(QContext.getQBackendTransaction());
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
{
InsertInput input = new InsertInput(table).withRecords(List.of(row)).withTransaction(QContext.getQBackendTransaction());
input.setTableMetaData(serviceMetadata(table));
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);
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()));
}
}
}
}
}
@@ -60,16 +60,42 @@ public final class CollaborationSchema
columns.add(" " + field.getBackendName() + " " + type + (field.getName().equals("parentDeleted") ? " 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("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("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 +119,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(com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).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,98 @@
/*
* 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.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;
/*******************************************************************************
** 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)
{
RuntimeMetaDataOverlay.refresh(instance);
}
else
{
var context = new CapturedContext(instance, QContext.getQSession());
transaction.addAfterCommitCallback(() ->
{
try
{
QContext.withTemporaryContext(context, () -> RuntimeMetaDataOverlay.refresh(instance));
}
catch(QException e)
{
throw (new IllegalStateException("Could not refresh collaboration policy", e));
}
});
}
}
}
@@ -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.
*******************************************************************************/
@@ -296,6 +310,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 +332,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,53 @@
/*
* 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;
/*******************************************************************************
** 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.insert(CollaborationTables.ACTIVITY, 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));
}
}
@@ -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,101 @@
/*
* 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.0.0")
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence")
.withSpec(PostPresenceSpec.class).withSpec(DeletePresenceSpec.class).withSpec(GetPresenceSpec.class)
.withSpec(ListFavoritesSpec.class)
.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(UploadAttachmentSpec.class));
}
}
@@ -0,0 +1,396 @@
/*
* 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.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", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", "CollaborationErrorV1")));
}
/*******************************************************************************
** 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);
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"));
}
}
int start = Math.min(offset, values.size());
int end = start + Math.min(limit, values.size() - start);
String next = end < values.size() ? Base64.getUrlEncoder().withoutPadding().encodeToString((prefix + end).getBytes(StandardCharsets.UTF_8)) : null;
return (new Page<>(values.subList(start, end), next));
}
/*******************************************************************************
** 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);
}
}
@@ -0,0 +1,125 @@
/*
* 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()
{
}
/*******************************************************************************
** All identity information is derived from the authenticated session.
*******************************************************************************/
public record CollaborationPresenceRequestV1(String connectionId, String table, String pk, String mode, String fieldName) implements ToSchema
{
/*******************************************************************************
** 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)))));
}
}
/*******************************************************************************
** Leave removes only this authenticated user's connection and target.
*******************************************************************************/
public record CollaborationPresenceLeaveV1(String connectionId, String table, String pk) implements ToSchema
{
/*******************************************************************************
** 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)))));
}
}
/*******************************************************************************
** 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));
}
}
@@ -0,0 +1,720 @@
/*
* 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) implements ToSchema
{
/*******************************************************************************
** 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 change or delete this attachment.").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) implements ToSchema
{
/*******************************************************************************
** 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("Presigned URL, valid for five minutes. Clients follow url; never persist it.").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("Standard QQQ error response.").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.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()))).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());
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.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
{
return (new CollaborationDownloadV1(new AttachmentsService().downloadUrl(id(input), false).toExternalForm()));
}
}
@@ -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,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.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
{
var page = page(input, new AttachmentsService().list(table(input), pk(input)));
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), 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.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,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()));
}
}
@@ -0,0 +1,85 @@
/*
* 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.CollaborationFavoriteOrderRequestV1;
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;
/*******************************************************************************
** Reorder personal favorites.
*******************************************************************************/
public class OrderFavoritesSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.PATCH, "/favorites/order", "Reorder personal favorites"));
}
/*******************************************************************************
** Describe path and query inputs.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the versioned response.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationMutationResultV1");
}
/*******************************************************************************
** Name the versioned request body.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("CollaborationFavoriteOrderRequestV1"));
}
/*******************************************************************************
** Invoke the service with the authenticated caller's context.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationFavoriteOrderRequestV1.class);
FavoritesService.reorder(body.ids(), folders(body.folders()));
return (success());
}
}
@@ -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.CollaborationAttachmentPatchRequestV1;
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;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Rename or describe an attachment.
*******************************************************************************/
public class PatchAttachmentSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.PATCH, "/attachments/{id}", "Rename or describe an attachment"));
}
/*******************************************************************************
** Describe path and query inputs.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the versioned response.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationAttachmentV1");
}
/*******************************************************************************
** Name the versioned request body.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("CollaborationAttachmentPatchRequestV1"));
}
/*******************************************************************************
** Invoke the service with the authenticated caller's context.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationAttachmentPatchRequestV1.class);
return (CollaborationViews.attachment(new AttachmentsService().rename(id(input), body.fileName(), body.description())));
}
}
@@ -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.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;
/*******************************************************************************
** PatchTask endpoint.
*******************************************************************************/
public class PatchTaskSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Check the stored parent before parsing any submitted body.
*******************************************************************************/
@Override
public JsonInput buildInput(io.javalin.http.Context context) throws Exception
{
try
{
Integer key = Integer.valueOf(context.pathParam("id"));
if(key < 1)
{
throw (new NumberFormatException());
}
new com.kingsrook.qbits.collaboration.tasks.TasksService().requireManager(key);
}
catch(NumberFormatException e)
{
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("An identifier must be a positive integer"));
}
return (super.buildInput(context));
}
/*******************************************************************************
** Describe this operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.PATCH, "/tasks/{id}", "Update 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("CollaborationTaskPatchRequestV1"));
}
/*******************************************************************************
** Run the service as the authenticated caller.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var service = new com.kingsrook.qbits.collaboration.tasks.TasksService();
var old = service.requireManager(id(input));
var body = body(input, CollaborationWorkSchemas.CollaborationTaskPatchRequestV1.class);
String status = body.status();
if(input.getBody().containsKey("status") && (status == null || !List.of("OPEN", "DONE", "CANCELLED").contains(status)))
{
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Unknown task status"));
}
var row = old;
if(input.getBody().keySet().stream().anyMatch(key -> !key.equals("status")))
{
row = service.update(id(input), input.getBody().containsKey("title") ? required(body.title(), "title") : old.getValueString("title"),
input.getBody().containsKey("notes") ? body.notes() : old.getValueString("notes"),
input.getBody().containsKey("assigneeUserId") ? body.assigneeUserId() : old.getValueString("assigneeUserId"),
input.getBody().containsKey("dueAt") ? instant(body.dueAt()) : old.getValueInstant("dueAt"),
input.getBody().containsKey("remindAt") ? instant(body.remindAt()) : old.getValueInstant("remindAt"));
}
if(status != null)
{
row = switch(status)
{
case "DONE" -> service.complete(id(input));
case "CANCELLED" -> service.cancel(id(input));
default -> service.reopen(id(input));
};
}
return (CollaborationViews.task(row));
}
}
@@ -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.CollaborationCommentPostRequestV1;
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;
/*******************************************************************************
** Post a comment or reply.
*******************************************************************************/
public class PostCommentSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.POST, "/records/{table}/{pk}/comments", "Post a comment or reply"));
}
/*******************************************************************************
** 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("CollaborationCommentPostRequestV1"));
}
/*******************************************************************************
** Invoke the service with the authenticated caller's context.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationCommentPostRequestV1.class);
return (CollaborationViews.comment(new CommentsService().post(table(input), pk(input), body.body(), body.parentCommentId(), body.fieldName())));
}
}
@@ -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;
/*******************************************************************************
** Renew presence with fresh parent access checks.
*******************************************************************************/
public class PostPresenceSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the extension operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.POST, "/presence", "Renew presence"));
}
/*******************************************************************************
** Explicit query parameters for polling.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationPresenceSnapshotV1");
}
/*******************************************************************************
** Declare a typed JSON request body.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("CollaborationPresenceRequestV1"));
}
/*******************************************************************************
** Services derive identity from QContext and enforce visibility.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceRequestV1.class);
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(body.table(), body.pk(),
new PresenceService().heartbeat(body.connectionId(), required(body.table(), "table"), body.pk(), body.mode(), body.fieldName())));
}
}
@@ -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.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 preview URL; clients follow url.
*******************************************************************************/
public class PreviewAttachmentSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.GET, "/attachments/{id}/preview", "Get a temporary preview 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
{
return (new CollaborationDownloadV1(new AttachmentsService().downloadUrl(id(input), true).toExternalForm()));
}
}
@@ -0,0 +1,83 @@
/*
* 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;
/*******************************************************************************
** PreviewShares endpoint.
*******************************************************************************/
public class PreviewSharesSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe this operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.POST, "/records/{table}/{pk}/shares/preview", "Preview share recipients"));
}
/*******************************************************************************
** Declare inputs.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the response.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationSharePreviewV1");
}
/*******************************************************************************
** 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);
var values = new com.kingsrook.qbits.collaboration.sharing.SharingService().preview(table(input), pk(input), recipients(body.toUserIds()));
return (new CollaborationWorkSchemas.CollaborationSharePreviewV1(values.stream()
.map(value -> new CollaborationWorkSchemas.CollaborationShareRecipientV1(value.userId(), value.displayName(), value.canRead())).toList()));
}
}
@@ -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;
/*******************************************************************************
** PutAssignment endpoint.
*******************************************************************************/
public class PutAssignmentSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe this operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.PUT, "/records/{table}/{pk}/assignment", "Set record owner and assignee"));
}
/*******************************************************************************
** Declare inputs.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the response.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationAssignmentV1");
}
/*******************************************************************************
** Name the request.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("CollaborationAssignmentRequestV1"));
}
/*******************************************************************************
** Run the service as the authenticated caller.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationWorkSchemas.CollaborationAssignmentRequestV1.class);
return (CollaborationViews.assignment(new com.kingsrook.qbits.collaboration.assignment.AssignmentService()
.assign(table(input), pk(input), body.ownerUserId(), body.assigneeUserId())));
}
}
@@ -0,0 +1,85 @@
/*
* 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.CollaborationFollowRequestV1;
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;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Follow a record and configure notifications.
*******************************************************************************/
public class PutFollowSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.PUT, "/records/{table}/{pk}/follow", "Follow a record and configure notifications"));
}
/*******************************************************************************
** Describe path and query inputs.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the versioned response.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationFollowStateV1");
}
/*******************************************************************************
** Name the versioned request body.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("CollaborationFollowRequestV1"));
}
/*******************************************************************************
** Invoke the service with the authenticated caller's context.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationFollowRequestV1.class);
return (new CollaborationFollowStateV1(true, CollaborationViews.follow(new FollowService().follow(table(input), pk(input), body.notifyOnChange(), body.notifyOnComment()))));
}
}
@@ -0,0 +1,85 @@
/*
* 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.CollaborationTargetRequestV1;
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;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Record a recent view.
*******************************************************************************/
public class RecordRecentSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.POST, "/recents", "Record a recent view"));
}
/*******************************************************************************
** Describe path and query inputs.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the versioned response.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationMutationResultV1");
}
/*******************************************************************************
** Name the versioned request body.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("CollaborationTargetRequestV1"));
}
/*******************************************************************************
** Invoke the service with the authenticated caller's context.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationTargetRequestV1.class);
RecentsService.recordView(body.targetType(), body.targetName(), body.recordId());
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;
/*******************************************************************************
** Reopen a comment thread.
*******************************************************************************/
public class ReopenCommentSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.POST, "/comments/{id}/reopen", "Reopen 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().reopen(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;
/*******************************************************************************
** Resolve a comment thread.
*******************************************************************************/
public class ResolveCommentSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.POST, "/comments/{id}/resolve", "Resolve 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().resolve(id(input));
return (success());
}
}
@@ -0,0 +1,85 @@
/*
* 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.CollaborationTargetRequestV1;
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;
/*******************************************************************************
** Star a target.
*******************************************************************************/
public class StarFavoriteSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.PUT, "/favorites", "Star a target"));
}
/*******************************************************************************
** Describe path and query inputs.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the versioned response.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationMutationResultV1");
}
/*******************************************************************************
** Name the versioned request body.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("CollaborationTargetRequestV1"));
}
/*******************************************************************************
** Invoke the service with the authenticated caller's context.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationTargetRequestV1.class);
FavoritesService.star(body.targetType(), body.targetName(), body.recordId());
return (success());
}
}
@@ -0,0 +1,85 @@
/*
* 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.CollaborationTargetRequestV1;
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;
/*******************************************************************************
** Unstar a target.
*******************************************************************************/
public class UnstarFavoriteSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.DELETE, "/favorites", "Unstar a target"));
}
/*******************************************************************************
** Describe path and query inputs.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the versioned response.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationMutationResultV1");
}
/*******************************************************************************
** Name the versioned request body.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("CollaborationTargetRequestV1"));
}
/*******************************************************************************
** Invoke the service with the authenticated caller's context.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationTargetRequestV1.class);
FavoritesService.unstar(body.targetType(), body.targetName(), body.recordId());
return (success());
}
}
@@ -0,0 +1,186 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.api;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationAttachmentV1;
import com.kingsrook.qbits.collaboration.attachments.AttachmentsService;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.middleware.javalin.executors.AbstractMiddlewareExecutor;
import com.kingsrook.qqq.middleware.javalin.executors.io.AbstractMiddlewareInput;
import com.kingsrook.qqq.middleware.javalin.executors.io.AbstractMiddlewareOutputInterface;
import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.openapi.model.Content;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.RequestBody;
import com.kingsrook.qqq.openapi.model.Schema;
import io.javalin.http.Context;
import io.javalin.http.UploadedFile;
/*******************************************************************************
** Multipart adapter: exactly one file, streamed through the bounded attachment service.
*******************************************************************************/
public class UploadAttachmentSpec extends AbstractEndpointSpec<UploadAttachmentSpec.UploadInput, UploadAttachmentSpec.UploadOutput, UploadAttachmentSpec.UploadExecutor>
{
/*******************************************************************************
** Describe the upload operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (CollaborationJsonSpec.operation(HttpMethod.POST, "/records/{table}/{pk}/attachments", "Upload an attachment"));
}
/*******************************************************************************
** Parent coordinates.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (CollaborationJsonSpec.parametersFor(defineBasicOperation().getPath()));
}
/*******************************************************************************
** A named binary file and optional description, not a JSON upload.
*******************************************************************************/
@Override
public RequestBody defineRequestBody()
{
return (new RequestBody().withRequired(true).withContent(Map.of("multipart/form-data",
new Content().withSchema(new Schema().withRefToSchema("CollaborationAttachmentUploadRequestV1")))));
}
/*******************************************************************************
** Use the same typed attachment as JSON operations.
*******************************************************************************/
@Override
public BasicResponse defineBasicSuccessResponse()
{
return (new BasicResponse("Uploaded attachment", "CollaborationAttachmentV1"));
}
/*******************************************************************************
** Standard framework errors.
*******************************************************************************/
@Override
public List<BasicResponse> defineAdditionalBasicResponses()
{
return (CollaborationJsonSpec.errors());
}
/*******************************************************************************
** Authenticate and check the parent before asking Javalin for uploaded parts.
*******************************************************************************/
@Override
public UploadInput buildInput(Context context) throws Exception
{
ParentRecordAccess.requireFeature(context.pathParam("table"), context.pathParam("pk"), "attach");
if(context.contentType() == null || !context.contentType().toLowerCase(Locale.ROOT).startsWith("multipart/form-data"))
{
throw (new QBadRequestException("Use multipart/form-data with one file part named file"));
}
List<UploadedFile> files;
try
{
files = context.uploadedFiles();
}
catch(RuntimeException e)
{
throw (new QBadRequestException("Malformed multipart upload", e));
}
if(files.size() != 1 || context.uploadedFiles("file").size() != 1)
{
throw (new QBadRequestException("Exactly one file part named file is required"));
}
UploadInput input = new UploadInput();
input.table = context.pathParam("table");
input.pk = context.pathParam("pk");
input.file = files.getFirst();
input.description = context.formParam("description");
return (input);
}
/*******************************************************************************
** Serialize the wire record, keeping nullable display fields.
*******************************************************************************/
@Override
public void handleOutput(Context context, UploadOutput output)
{
context.contentType("application/json").result(JsonUtils.toJsonCustomized(output.attachment,
builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS)));
}
/*******************************************************************************
** Request parts remain scoped to this request.
*******************************************************************************/
public static class UploadInput extends AbstractMiddlewareInput
{
private String table;
private String pk;
private UploadedFile file;
private String description;
}
/*******************************************************************************
** The executor output contains only the public wire DTO.
*******************************************************************************/
public static class UploadOutput implements AbstractMiddlewareOutputInterface
{
private CollaborationAttachmentV1 attachment;
}
/*******************************************************************************
** The service enforces size, filename policy, permission, storage and cleanup.
*******************************************************************************/
public static class UploadExecutor extends AbstractMiddlewareExecutor<UploadInput, UploadOutput>
{
/*******************************************************************************
** Close the uploaded stream after the synchronous service call.
*******************************************************************************/
@Override
public void execute(UploadInput input, UploadOutput output) throws QException
{
try(InputStream stream = input.file.content())
{
output.attachment = CollaborationViews.attachment(new AttachmentsService().upload(input.table, input.pk,
input.file.filename(), input.file.contentType(), stream, input.description));
}
catch(IOException e)
{
throw (new QException("Could not read attachment upload", e));
}
}
}
}
@@ -24,15 +24,22 @@ package com.kingsrook.qbits.collaboration.app;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
@@ -41,26 +48,45 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOp
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData.Statistic;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** The Collaboration app and its home. Counts use QQQ actions with the current
** QContext session, preserving permissions and record security.
** The Collaboration app and its home, built from standard QQQ widgets: a KPI
** block and three full-width tables (tags, custom fields, who's here). Counts
** use QQQ actions with the current QContext session, so permissions and record
** security apply, and every block carries a real empty-state message. No colors
** are specified, so the frontend theme (including dark mode) decides.
*******************************************************************************/
public final class CollaborationHome
{
public static final String HOME_WIDGET = "collaborationHome";
public static final String SETUP_WIDGET = "collaborationSetup";
public static final String KPI_WIDGET = "collaborationKpis";
public static final String TAGS_WIDGET = "collaborationTags";
public static final String FIELDS_WIDGET = "collaborationCustomFields";
public static final String PRESENCE_WIDGET = "collaborationWhosHere";
public static final List<String> HOME_WIDGETS = List.of(SETUP_WIDGET, KPI_WIDGET, TAGS_WIDGET, FIELDS_WIDGET, PRESENCE_WIDGET);
public static final String NOBODY_HERE = "Nobody else is here right now.";
public static final String NO_TAGS = "No tags yet. Tags appear here once someone creates one on a record.";
public static final String NO_FIELDS = "No custom fields yet. Define one under Custom fields.";
public static final String PRESENCE_ADMIN_ONLY = "Who's here is visible to collaboration administrators.";
@@ -80,9 +106,9 @@ public final class CollaborationHome
{
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel("Collaboration").withIcon(new QIcon("forum"))
.withPermissionRules(CollaborationPermissions.permissionRules())
.withWidgets(List.of(HOME_WIDGET))
.withWidgets(HOME_WIDGETS)
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
.withTables(List.of(CollaborationTables.ATTACHMENT, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
app.setParentAppName(config.getParentAppName());
return (app);
}
@@ -90,98 +116,263 @@ public final class CollaborationHome
/*******************************************************************************
** Full-width HTML widget, following the reference Storage home renderer.
** The four full-width home widgets, in display order.
*******************************************************************************/
public static QWidgetMetaData widget()
public static List<QWidgetMetaData> widgets()
{
return (new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false)
return (List.of(
widget(SETUP_WIDGET, "Setup", WidgetType.TABLE, SetupRenderer.class),
widget(KPI_WIDGET, "Overview", WidgetType.MULTI_STATISTICS, KpiRenderer.class),
widget(TAGS_WIDGET, "Tags", WidgetType.TABLE, TagsRenderer.class),
widget(FIELDS_WIDGET, "Custom fields", WidgetType.TABLE, FieldsRenderer.class),
widget(PRESENCE_WIDGET, "Who's here", WidgetType.TABLE, PresenceRenderer.class)));
}
/*******************************************************************************
** One full-width card-less widget guarded by the shared permission rules.
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer)
{
return (new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType())
.withCodeReference(new QCodeReference(renderer)).withGridColumns(12).withIsCard(false)
.withPermissionRules(CollaborationPermissions.permissionRules()));
}
/*******************************************************************************
** Show persisted enabled overrides by scope type, and recent directory use.
** These are settings counts, not a claim about resolved host-table features.
** The Setup card: each record feature, whether it is on, for which tables,
** where that comes from, and the next step.
*******************************************************************************/
public static String render(Instant now) throws QException
public static TableData setup() throws QException
{
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
StringBuilder html = new StringBuilder("<div style=\"display:flex;flex-direction:column;gap:1rem;width:100%;overflow-wrap:anywhere;\"><section><h3>Feature settings</h3>");
long settings = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter());
if(settings == 0)
List<Map<String, Object>> rows = new ArrayList<>();
for(CollaborationSetupStatus.Item item : CollaborationSetupStatus.load(QContext.getQInstance()))
{
html.append("<p>No feature settings yet: features follow the code defaults</p>");
Map<String, Object> row = new LinkedHashMap<>();
row.put("feature", item.feature());
row.put("state", item.state());
row.put("where", item.where());
row.put("source", item.source());
row.put("next", item.nextStep() == null ? "" : item.nextStep());
rows.add(row);
}
else
{
html.append("<p>Enabled feature settings per scope:</p><ul>");
for(String scope : List.of("INSTANCE", "APP", "TABLE"))
{
long enabled = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter()
.withCriteria(new QFilterCriteria("scopeType", QCriteriaOperator.EQUALS, scope))
.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)));
html.append("<li>").append(scope).append(": ").append(enabled).append("</li>");
}
html.append("</ul><p>Stored overrides; applying them awaits the core collaboration contracts.</p>");
}
long people = count(CollaborationTables.USER, new QQueryFilter());
long recent = count(CollaborationTables.USER, new QQueryFilter()
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS)))
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)));
html.append("</section><section><h3>People</h3><p>People known: ").append(people)
.append("</p><p>Active in the last 7 days: ").append(recent).append("</p>");
if(people == 0)
{
html.append("<p>No people yet: the directory will be filled by collaboration calls when the feature services are installed.</p>");
}
html.append("</section>");
personalSummary(html, CollaborationTables.FAVORITE, "Favorites");
personalSummary(html, CollaborationTables.RECENT, "Recently viewed");
attachmentSummary(html);
return (html.append("</div>").toString());
return (table("Setup", "Nothing to set up.", rows, column("Feature", "feature", "left"), column("State", "state", "left"), column("Where", "where", "left"),
column("From", "source", "left"), column("Next step", "next", "left")));
}
/*******************************************************************************
** Real table aggregates, restricted to readable parent tables. Parent locks
** are post-filtered on rows, so these aggregate totals are approximate.
** The KPI groups: conversations, work, files and custom fields.
*******************************************************************************/
private static void attachmentSummary(StringBuilder html) throws QException
public static MultiStatisticsData kpis(Instant now) throws QException
{
Aggregate count = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
Instant weekAgo = now.minus(7, ChronoUnit.DAYS);
int commentsThisWeek = count(CollaborationTables.COMMENT, new QQueryFilter()
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, weekAgo)
.withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK));
int openThreads = count(CollaborationTables.COMMENT, new QQueryFilter().withCriteria("threadId", QCriteriaOperator.IS_BLANK)
.withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK));
int openTasks = count(CollaborationTables.TASK, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"));
int overdue = count(CollaborationTables.TASK, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN")
.withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now));
int openRequests = count(CollaborationTables.ACCESS_REQUEST, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"));
int activeFields = count(CollaborationTables.FIELD_DEFINITION, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
Aggregate files = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
Aggregate bytes = new Aggregate().withFieldName("sizeBytes").withOperator(AggregateOperator.SUM);
AggregateInput input = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(count, bytes));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<AggregateResult> results = new AggregateAction().execute(input).getResults();
Object rowCount = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(count);
Object totalBytes = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(bytes);
html.append("<section><h3>Attachments</h3><p>Attachments: ").append(rowCount)
.append("</p><p>Total size (bytes): ").append(totalBytes == null ? 0 : totalBytes)
.append("</p><p>Approximate totals: parent record locks are applied when browsing attachments.</p>");
if("0".equals(String.valueOf(rowCount)))
{
html.append("<p>No attachments yet.</p>");
}
html.append("</section>");
AggregateInput aggregateInput = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(files, bytes));
aggregateInput.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<AggregateResult> results = new AggregateAction().execute(aggregateInput).getResults();
int attachments = results.isEmpty() ? 0 : numberOf(results.getFirst().getAggregateValue(files)).intValue();
long totalBytes = results.isEmpty() ? 0 : numberOf(results.getFirst().getAggregateValue(bytes)).longValue();
String fileSubheader = attachments == 0 ? "No attachments yet" : humanSize(totalBytes) + " in total";
return (new MultiStatisticsData("Collaboration", List.of(
new StatisticsGroupData("forum", null, "Conversations", "Last 7 days and open", List.of(
new Statistic("Comments this week", commentsThisWeek, path(CollaborationTables.COMMENT)),
new Statistic("Open threads", openThreads, path(CollaborationTables.COMMENT)))),
new StatisticsGroupData("task_alt", null, "Work", openTasks == 0 && openRequests == 0 ? "Nothing waiting" : "Waiting on someone", List.of(
new Statistic("Open tasks", openTasks, path(CollaborationTables.TASK)),
new Statistic("Overdue tasks", overdue, path(CollaborationTables.TASK)),
new Statistic("Open access requests", openRequests, path(CollaborationTables.ACCESS_REQUEST)))),
new StatisticsGroupData("attach_file", null, "Attachments", fileSubheader, List.of(
new Statistic("Attachments", attachments, path(CollaborationTables.ATTACHMENT)))),
new StatisticsGroupData("tune", null, "Custom fields", activeFields == 0 ? "None active yet" : "Defined by administrators", List.of(
new Statistic("Active custom fields", activeFields, path(CollaborationTables.FIELD_DEFINITION)))))));
}
/*******************************************************************************
** Aggregate rows and distinct owners without loading high-volume personal data.
** Approved and pending tags, most used first: label, table label, records.
*******************************************************************************/
private static void personalSummary(StringBuilder html, String table, String label) throws QException
public static TableData tags() throws QException
{
Aggregate rows = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
Aggregate users = new Aggregate().withFieldName("userId").withOperator(AggregateOperator.COUNT_DISTINCT);
AggregateInput input = new AggregateInput(table).withAggregates(List.of(rows, users));
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
QueryInput input = new QueryInput(CollaborationTables.TAG).withFilter(new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK)
.withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(200));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<AggregateResult> results = new AggregateAction().execute(input).getResults();
html.append("<section><h3>").append(label).append("</h3><p>Rows: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(rows))
.append("</p><p>Distinct users: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(users)).append("</p></section>");
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord tag : new QueryAction().execute(input).getRecords())
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("label", tag.getValueString("label"));
row.put("table", tableLabel(tag.getValueString("tableName")));
row.put("records", tag.getValueInteger("usageCount") == null ? 0 : tag.getValueInteger("usageCount"));
rows.add(row);
}
return (table("Tags", NO_TAGS, rows, column("Label", "label", "left"), column("Table", "table", "left"), column("Records", "records", "right")));
}
/*******************************************************************************
** Custom field definitions: name, table, status and index state.
*******************************************************************************/
public static TableData fields() throws QException
{
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
QueryInput input = new QueryInput(CollaborationTables.FIELD_DEFINITION).withFilter(new QQueryFilter()
.withOrderBy(new QFilterOrderBy("tableName")).withOrderBy(new QFilterOrderBy("name")).withLimit(200));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord definition : new QueryAction().execute(input).getRecords())
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", definition.getValueString("name"));
row.put("table", tableLabel(definition.getValueString("tableName")));
row.put("status", friendly(definition.getValueString("status")));
row.put("indexState", friendly(definition.getValueString("indexState")));
rows.add(row);
}
return (table("Custom fields", NO_FIELDS, rows, column("Name", "name", "left"), column("Table", "table", "left"),
column("Status", "status", "left"), column("Index state", "indexState", "left")));
}
/*******************************************************************************
** Live viewer counts per table. Only administrators see them (they cover
** records the viewer may not be able to open); others get an explanation.
*******************************************************************************/
public static TableData presence() throws QException
{
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
boolean admin = QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN);
List<Map<String, Object>> rows = new ArrayList<>();
if(admin)
{
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("table", tableLabel(table));
row.put("viewers", viewers);
rows.add(row);
});
}
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right")));
}
/*******************************************************************************
** Assemble a table widget whose empty state is a sentence, not a blank grid.
*******************************************************************************/
private static TableData table(String label, String emptyMessage, List<Map<String, Object>> rows, TableData.Column... columns)
{
return (new TableData(label, List.of(columns), rows).withNoRowsFoundHTML(emptyMessage).withRowsPerPage(10));
}
/*******************************************************************************
** A plain column; the frontend styles it.
*******************************************************************************/
private static TableData.Column column(String header, String accessor, String align)
{
//////////////////////////////////////////////////////////////////
// no width: frontends size the columns to fit narrow (390 px) cards //
//////////////////////////////////////////////////////////////////
return (new TableData.Column("default", header, accessor, null, align));
}
/*******************************************************************************
** A table's display label, "All tables" when there is none (global).
*******************************************************************************/
private static String tableLabel(String name)
{
if(name == null || name.isBlank())
{
return ("All tables");
}
QTableMetaData table = QContext.getQInstance().getTable(name);
return (table == null || table.getLabel() == null ? name : table.getLabel());
}
/*******************************************************************************
** ACTIVE becomes Active; a missing value becomes a dash.
*******************************************************************************/
private static String friendly(String value)
{
if(value == null || value.isBlank())
{
return ("-");
}
String lower = value.toLowerCase(Locale.ROOT).replace('_', ' ');
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
}
/*******************************************************************************
** Binary sizes with one decimal, like 4.7 KB; whole bytes below 1 KB.
*******************************************************************************/
public static String humanSize(long bytes)
{
if(bytes < 1024)
{
return (bytes + " B");
}
String[] units = { "KB", "MB", "GB", "TB", "PB" };
double value = bytes;
int unit = -1;
while(value >= 1024 && unit < units.length - 1)
{
value /= 1024;
unit++;
}
return (String.format(Locale.ROOT, "%.1f %s", value, units[unit]));
}
/*******************************************************************************
** Aggregate values come back as any numeric type, or null for empty sets.
*******************************************************************************/
private static Number numberOf(Object value)
{
return (value instanceof Number number ? number : 0);
}
/*******************************************************************************
** The path of a Collaboration table page, under the parent app when configured.
*******************************************************************************/
private static String path(String table)
{
CollaborationQBitConfig config = CollaborationRecords.config();
String prefix = config.getParentAppName() == null ? "/" + config.getAppName() : "/" + config.getParentAppName() + "/" + config.getAppName();
return (prefix + "/" + table);
}
@@ -189,7 +380,7 @@ public final class CollaborationHome
/*******************************************************************************
** Count without materializing rows or substituting a privileged session.
*******************************************************************************/
private static long count(String table, QQueryFilter filter) throws QException
private static int count(String table, QQueryFilter filter) throws QException
{
CountInput input = new CountInput(table).withFilter(filter);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
@@ -199,9 +390,26 @@ public final class CollaborationHome
/*******************************************************************************
** Widget renderer instantiated by QQQ.
** Setup renderer instantiated by QQQ.
*******************************************************************************/
public static class HomeRenderer extends AbstractWidgetRenderer
public static class SetupRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(setup()));
}
}
/*******************************************************************************
** KPI renderer instantiated by QQQ.
*******************************************************************************/
public static class KpiRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session and time.
@@ -209,7 +417,58 @@ public final class CollaborationHome
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(new RawHTML(null, CollaborationHome.render(Instant.now()))));
return (new RenderWidgetOutput(kpis(Instant.now())));
}
}
/*******************************************************************************
** Tags renderer instantiated by QQQ.
*******************************************************************************/
public static class TagsRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(tags()));
}
}
/*******************************************************************************
** Custom fields renderer instantiated by QQQ.
*******************************************************************************/
public static class FieldsRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(fields()));
}
}
/*******************************************************************************
** Presence renderer instantiated by QQQ.
*******************************************************************************/
public static class PresenceRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(presence()));
}
}
}
@@ -0,0 +1,129 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.app;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** The Setup card on the Collaboration home: which record features are on, for
** which tables, and whether that comes from Feature Settings rows or from the
** code defaults. Uses the same resolution the runtime uses
** (RecordFeaturesResolver: code declarations, then the committed rows), so the
** card never reports something the runtime would not do.
*******************************************************************************/
public final class CollaborationSetupStatus
{
private static final int NAMED_TABLES = 5;
/*******************************************************************************
** One line of the card.
*******************************************************************************/
public record Item(String feature, String state, String where, String source, String nextStep)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CollaborationSetupStatus()
{
}
/*******************************************************************************
** One line for Feature Settings itself, then one per record feature.
*******************************************************************************/
public static List<Item> load(QInstance instance) throws QException
{
List<QRecord> rows = CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter().withLimit(1000));
Set<String> featuresWithRows = new HashSet<>();
rows.forEach(row -> featuresWithRows.add(row.getValueString("feature")));
List<Item> items = new ArrayList<>();
items.add(rows.isEmpty()
? new Item("Feature settings", "Using code defaults", "-", "Configuration", "Run Create rows from configuration on Feature Settings to see and edit every setting.")
: new Item("Feature settings", "Set up", rows.size() + " row" + (rows.size() == 1 ? "" : "s"), "Feature Settings", null));
List<QTableMetaData> hostTables = CollectionUtils.nonNullMap(instance.getTables()).values().stream()
.filter(table -> !table.getName().startsWith("collaboration_") && !Boolean.TRUE.equals(table.getIsHidden()) && table.getPrimaryKeyField() != null
&& (table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_GET) || table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_QUERY)))
.sorted((a, b) -> a.getName().compareTo(b.getName())).toList();
for(RecordFeature feature : RecordFeature.values())
{
List<String> on = new ArrayList<>();
for(QTableMetaData table : hostTables)
{
if(RecordFeaturesResolver.resolve(instance, table).getFeatures().get(feature).getEnabled())
{
on.add(table.getLabel() == null ? table.getName() : table.getLabel());
}
}
String source = featuresWithRows.contains(feature.name()) ? "Feature Settings" : "Configuration";
String name = friendly(feature);
if(on.isEmpty())
{
items.add(new Item(name, "Off", "No table", source, "Add a Feature Settings row (scope INSTANCE, or TABLE for one table) and turn it on."));
}
else
{
boolean all = on.size() == hostTables.size();
String where = all ? "Every table" : String.join(", ", on.subList(0, Math.min(NAMED_TABLES, on.size()))) + (on.size() > NAMED_TABLES ? " and " + (on.size() - NAMED_TABLES) + " more" : "");
items.add(new Item(name, all ? "On" : "On for " + on.size() + " of " + hostTables.size() + " tables", where, source, null));
}
}
return (items);
}
/*******************************************************************************
** TAGS becomes Tags, CUSTOM_FIELDS becomes Custom fields.
*******************************************************************************/
static String friendly(RecordFeature feature)
{
String lower = feature.name().toLowerCase(Locale.ROOT).replace('_', ' ');
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
}
}
@@ -0,0 +1,156 @@
/*
* 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.assignment;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.WorkSupport;
import com.kingsrook.qbits.collaboration.activity.ActivityWriter;
import com.kingsrook.qbits.collaboration.follow.FollowService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.Notifications;
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.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Ownership storage for hosts without native RecordOwnership columns.
*******************************************************************************/
public class AssignmentService
{
/*******************************************************************************
** Full replacement of both roles; null clears a role.
*******************************************************************************/
public QRecord assign(String table, Serializable recordId, String ownerUserId, String assigneeUserId) throws QException
{
ParentRecordAccess.requireFeature(table, recordId, "assign");
String id = FollowService.canonicalId(table, recordId);
WorkSupport.requireReader(ownerUserId, table, id);
WorkSupport.requireReader(assigneeUserId, table, id);
synchronized(CollaborationRecords.config())
{
Optional<QRecord> old = get(table, id);
QRecord change = new QRecord().withValue("ownerUserId", ownerUserId).withValue("assigneeUserId", assigneeUserId);
QRecord row;
if(old.isEmpty())
{
row = CollaborationRecords.insert(CollaborationTables.RECORD_ASSIGNMENT, change.withValue("tableName", table).withValue("recordId", id).withValue("parentDeleted", false));
}
else
{
CollaborationRecords.update(CollaborationTables.RECORD_ASSIGNMENT, change.withValue("id", old.get().getValue("id")));
row = get(table, id).orElseThrow();
}
ActivityWriter.write(table, id, "ASSIGNED", "Record assignment changed", CollaborationTables.RECORD_ASSIGNMENT, row.getValueString("id"));
if(assigneeUserId != null && (old.isEmpty() || !Objects.equals(old.get().getValueString("assigneeUserId"), assigneeUserId)))
{
new FollowService().ensureFollower(assigneeUserId, table, id, "ASSIGNED");
if(!assigneeUserId.equals(PersonalRecords.currentUserId()))
{
Notifications.send(new CollaborationNotification(assigneeUserId, "A record was assigned to you", null,
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)).withEvent(row.getValue("id"), PersonalRecords.currentUserId()));
}
}
return (row);
}
}
/*******************************************************************************
** Parent readers can see assignment independently of the admin-only table nav.
*******************************************************************************/
public Optional<QRecord> get(String table, Serializable recordId) throws QException
{
return (CollaborationRecords.query(CollaborationTables.RECORD_ASSIGNMENT, CollaborationRecords.parent(table, FollowService.canonicalId(table, recordId))).stream().findFirst());
}
/*******************************************************************************
** Counts cover all visible matches; limit bounds the rows returned per table.
*******************************************************************************/
public Map<String, Group> myRecords(String table, Integer limit) throws QException
{
int size = limit == null ? 20 : limit;
if(size < 1 || size > 100)
{
throw (new QException("Limit must be between 1 and 100."));
}
Map<String, List<QRecord>> groups = new LinkedHashMap<>();
for(QRecord row : mineRows(table, false))
{
groups.computeIfAbsent(row.getValueString("tableName"), key -> new ArrayList<>()).add(row);
}
Map<String, Group> result = new LinkedHashMap<>();
groups.forEach((name, rows) -> result.put(name, new Group((long) rows.size(), rows.stream().limit(size).toList())));
return (result);
}
/*******************************************************************************
** Fresh parent checks protect both personal rows and counts after access changes.
*******************************************************************************/
public List<QRecord> mineRows(String table, Boolean assignedOnly) throws QException
{
String user = PersonalRecords.currentUserId();
QQueryFilter roles = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("assigneeUserId", QCriteriaOperator.EQUALS, user);
if(!Boolean.TRUE.equals(assignedOnly))
{
roles.withCriteria("ownerUserId", QCriteriaOperator.EQUALS, user);
}
QQueryFilter filter = new QQueryFilter().withSubFilter(roles).withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false)
.withOrderBy(new QFilterOrderBy("tableName")).withOrderBy(new QFilterOrderBy("id"));
if(table != null)
{
filter.withCriteria("tableName", QCriteriaOperator.EQUALS, table);
}
List<QRecord> visible = new ArrayList<>();
for(QRecord row : CollaborationRecords.query(CollaborationTables.RECORD_ASSIGNMENT, filter))
{
try
{
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
visible.add(row);
}
catch(QNotFoundException expected)
{
// Revoked access must not contribute even to the count.
}
}
return (visible);
}
/*******************************************************************************
** Total and first page for one host table.
*******************************************************************************/
public record Group(Long count, List<QRecord> records)
{
}
}
@@ -61,9 +61,11 @@ public class AttachmentCleanup
{
return;
}
new com.kingsrook.qbits.collaboration.WorkCleanup().onParentsDeleted(table, ids);
new com.kingsrook.qbits.collaboration.tags.TagsService().onParentsDeleted(table, ids);
while(true)
{
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
List<QRecord> rows = com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupQuery(com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()).withLimit(200));
if(rows.isEmpty())
{
@@ -71,7 +73,7 @@ public class AttachmentCleanup
}
for(QRecord row : rows)
{
service.deleteStoredRow(row);
service.deleteStoredRow(row, true);
}
}
}
@@ -49,7 +49,7 @@ final class AttachmentRecords
*******************************************************************************/
static List<QRecord> query(QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(CollaborationTables.ATTACHMENT).withFilter(filter).withShouldFetchHeavyFields(true);
QueryInput input = new QueryInput(CollaborationTables.ATTACHMENT).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(QContext.getQBackendTransaction());
input.setTableMetaData(storageTable());
input.withPermissionBypass("Attachment service checks parent access; cleanup is a trusted after-delete callback.");
return (new QueryAction().execute(input).getRecords());
@@ -21,25 +21,17 @@
package com.kingsrook.qbits.collaboration.attachments;
import java.util.List;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissionChecker;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrCountInputInterface;
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.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** RC.5 has no PRE_QUERY customizer. Its permission checker provides the
** pre-query restriction for query, count and aggregate; post-query checks
** handle parent record locks. Direct writes must use the attachment service.
** Core reference metadata protects reads; direct writes use the attachment service.
*******************************************************************************/
public class AttachmentSecurity implements CustomPermissionChecker
{
@@ -54,27 +46,5 @@ public class AttachmentSecurity implements CustomPermissionChecker
throw (new QPermissionDeniedException("Use the attachment service or Delete attachments action."));
}
new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata);
if(input instanceof QueryOrCountInputInterface query)
{
restrict(query);
}
}
/*******************************************************************************
** AND the restriction outside any user OR clauses; preserve paging and sort.
** Counts and aggregates are approximate because parent locks are post-filtered.
*******************************************************************************/
public static void restrict(QueryOrCountInputInterface input)
{
List<String> readable = QContext.getQInstance().getTables().keySet().stream().filter(ParentRecordAccess::canReadTable).toList();
QQueryFilter original = input.getFilter();
QQueryFilter filter = new QQueryFilter().withCriteria("tableName", QCriteriaOperator.IN, readable)
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false);
if(original != null)
{
filter.withSubFilter(original).withLimit(original.getLimit()).withSkip(original.getSkip());
filter.setOrderBys(original.getOrderBys());
}
input.setFilter(filter);
}
}
@@ -45,11 +45,13 @@ public class AttachmentStorage
}
/*******************************************************************************
** Enforce the bucket's cap as well as collaboration's configured cap.
** The bucket's own upload cap, from object storage's api package; Long.MAX_VALUE
** when the bucket has no limit. Collaboration also enforces its per-table cap.
*******************************************************************************/
public long maxBytes(String name)
{
return (bucket(name).config().getMaxUploadBytes());
Long max = bucket(name).maxUploadBytes();
return (max == null ? Long.MAX_VALUE : max);
}
/*******************************************************************************
@@ -49,6 +49,7 @@ 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.UpdateAction;
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;
@@ -73,8 +74,9 @@ public class AttachmentsService
*******************************************************************************/
public AttachmentsService()
{
this((CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
.filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig(), new AttachmentStorage());
this.config = (CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
.filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig();
this.storage = com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader.getAdHoc(AttachmentStorage.class, config.getAttachmentStorage());
}
/*******************************************************************************
@@ -92,6 +94,7 @@ public class AttachmentsService
*******************************************************************************/
public AttachmentEntry upload(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description) throws QException
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
ParentRecordAccess.requireFeature(tableName, recordId, "attach");
String user = requireUser();
String id = canonicalId(tableName, recordId);
@@ -101,7 +104,7 @@ public class AttachmentsService
if(tableName.length() > 128 || id.length() > 64 || user.length() > 255 || bucket.length() > 255 || key.length() > 1024
|| (contentType != null && contentType.length() > 255))
{
throw (new QException("Attachment metadata exceeds the supported field lengths."));
throw (new QBadRequestException("Attachment metadata exceeds the supported field lengths."));
}
long limit = Math.min(config.getDefaultMaxBytes(), storage.maxBytes(bucket));
Path temporary = null;
@@ -118,7 +121,7 @@ public class AttachmentsService
{
if(read > limit - size)
{
throw (new QException("Attachment exceeds the size limit of " + limit + " bytes."));
throw (new QBadRequestException("Attachment exceeds the size limit of " + limit + " bytes."));
}
size += read;
digest.update(buffer, 0, read);
@@ -130,23 +133,29 @@ public class AttachmentsService
{
storage.put(bucket, PutRequest.of(key, upload).withContentLength(size).withContentType(type).withContentDisposition("inline"));
}
boolean rowSaved = false;
try
{
QRecord row = new QRecord().withValue("tableName", tableName).withValue("recordId", id).withValue("fileName", name)
.withValue("contentType", type).withValue("sizeBytes", size).withValue("sha256", HexFormat.of().formatHex(digest.digest()))
.withValue("bucketName", bucket).withValue("storageKey", key).withValue("description", description)
.withValue("createdByUserId", user).withValue("parentDeleted", false);
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withRecords(List.of(row));
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withRecords(List.of(row));
input.withPermissionBypass("AttachmentsService checked parent and attach permission before upload.");
List<QRecord> records = new InsertAction().execute(input).getRecords();
AttachmentRecords.checkErrors(records);
rowSaved = true;
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(tableName, id, "FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, records.getFirst().getValueString("id"));
return (entry(records.getFirst()));
}
catch(QException | RuntimeException failure)
{
try
{
storage.delete(bucket, key);
if(!rowSaved)
{
storage.delete(bucket, key);
}
}
catch(RuntimeException cleanupFailure)
{
@@ -180,6 +189,7 @@ public class AttachmentsService
*******************************************************************************/
public List<AttachmentEntry> list(String tableName, Serializable recordId) throws QException
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
String id = canonicalId(tableName, recordId);
List<AttachmentEntry> entries = new ArrayList<>();
for(QRecord row : AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName)
@@ -197,6 +207,7 @@ public class AttachmentsService
*******************************************************************************/
public URL downloadUrl(Serializable attachmentId, boolean inline) throws QException
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
return (url(requireRow(attachmentId), inline));
}
@@ -218,6 +229,7 @@ public class AttachmentsService
*******************************************************************************/
public void delete(List<? extends Serializable> attachmentIds) throws QException
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
List<QRecord> rows = new ArrayList<>();
for(Serializable id : attachmentIds.stream().distinct().toList())
{
@@ -226,6 +238,7 @@ public class AttachmentsService
for(QRecord row : rows)
{
deleteStoredRow(row);
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(row.getValueString("tableName"), row.getValueString("recordId"), "FILE_REMOVED", "File removed", CollaborationTables.ATTACHMENT, row.getValueString("id"));
}
}
@@ -235,6 +248,7 @@ public class AttachmentsService
*******************************************************************************/
public AttachmentEntry rename(Serializable attachmentId, String fileName, String description) throws QException
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
QRecord row = requireMutable(attachmentId);
QRecord change = new QRecord().withValue("id", row.getValue("id"));
if(fileName != null)
@@ -245,7 +259,7 @@ public class AttachmentsService
{
change.setValue("description", description);
}
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT).withRecords(List.of(change));
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withRecords(List.of(change));
input.setTableMetaData(AttachmentRecords.storageTable());
input.withPermissionBypass("AttachmentsService checked the actual parent and author/admin before metadata update.");
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
@@ -265,16 +279,14 @@ public class AttachmentsService
*******************************************************************************/
public QRecord requireMutable(Serializable attachmentId) throws QException
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
QRecord row = requireRow(attachmentId);
requireUser();
if(!canDelete(row))
{
throw (new QPermissionDeniedException("Only the author or collaboration.admin can change this attachment."));
}
if(!isAdmin())
{
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
}
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
return (row);
}
@@ -282,10 +294,18 @@ public class AttachmentsService
** Delete storage first so a provider failure leaves a retryable row.
*******************************************************************************/
void deleteStoredRow(QRecord row) throws QException
{
deleteStoredRow(row, false);
}
/*******************************************************************************
** Core cleanup supplies an already deleted parent; user deletion retains checks.
*******************************************************************************/
void deleteStoredRow(QRecord row, boolean cleanup) throws QException
{
storage.delete(row.getValueString("bucketName"), row.getValueString("storageKey"));
DeleteInput input = new DeleteInput(CollaborationTables.ATTACHMENT).withPrimaryKeys(List.of(row.getValue("id")));
input.setTableMetaData(AttachmentRecords.storageTable());
DeleteInput input = new DeleteInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withPrimaryKeys(List.of(row.getValue("id")));
input.setTableMetaData(cleanup ? com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT) : AttachmentRecords.storageTable());
input.withPermissionBypass("Attachment service authorized deletion, or trusted parent cleanup selected this row.");
AttachmentRecords.checkErrors(new DeleteAction().execute(input).getRecordsWithErrors());
}
@@ -360,18 +380,18 @@ public class AttachmentsService
{
if(fileName == null || fileName.isBlank())
{
throw (new QException("A file name is required."));
throw (new QBadRequestException("A file name is required."));
}
String name = fileName.replace('\\', '/');
name = name.substring(name.lastIndexOf('/') + 1).replaceAll("[^A-Za-z0-9._ -]", "_").strip();
if(name.isBlank() || name.equals(".") || name.equals("..") || name.length() > 255)
{
throw (new QException("Invalid file name."));
throw (new QBadRequestException("Invalid file name."));
}
String extension = name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
if(config.getBlockedExtensions().stream().anyMatch(blocked -> extension.equals(blocked.replaceFirst("^\\.", "").toLowerCase(Locale.ROOT))))
{
throw (new QException("This file extension is blocked."));
throw (new QBadRequestException("This file extension is blocked."));
}
return (name);
}
@@ -92,7 +92,7 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final AttachmentsService service;
private AttachmentsService service;
private final ProcessSummaryLine selected = line(Status.OK, "will be deleted", "deleted");
private final ProcessSummaryLine skipped = line(Status.WARNING, "cannot be deleted (skipped)", "skipped");
@@ -101,7 +101,11 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public Review()
{
this(new AttachmentsService());
//////////////////////////////////////////////////////////////////////////
// no QContext here: QQQ constructs steps during context-free instance //
// validation, so the service is created on first use (in a run) //
//////////////////////////////////////////////////////////////////////////
this(null);
}
/*******************************************************************************
@@ -112,6 +116,20 @@ public final class DeleteAttachmentsProcess
this.service = service;
}
/*******************************************************************************
** The attachments service, created on first use inside a run (QContext exists).
*******************************************************************************/
private AttachmentsService service()
{
if(service == null)
{
service = new AttachmentsService();
}
return (service);
}
/*******************************************************************************
** Dry run only; no object or row mutations.
*******************************************************************************/
@@ -122,7 +140,7 @@ public final class DeleteAttachmentsProcess
{
try
{
QRecord checked = service.requireMutable(row.getValue("id"));
QRecord checked = service().requireMutable(row.getValue("id"));
output.getRecords().add(checked);
selected.incrementCountAndAddPrimaryKey(checked.getValue("id"));
}
@@ -151,7 +169,7 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public static class Delete extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final AttachmentsService service;
private AttachmentsService service;
private final ProcessSummaryLine deleted = line(Status.OK, "will be deleted", "deleted");
private final ProcessSummaryLine failed = line(Status.ERROR, "could not be deleted", "could not be deleted (retry required)");
@@ -160,7 +178,11 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public Delete()
{
this(new AttachmentsService());
//////////////////////////////////////////////////////////////////////////
// no QContext here: QQQ constructs steps during context-free instance //
// validation, so the service is created on first use (in a run) //
//////////////////////////////////////////////////////////////////////////
this(null);
}
/*******************************************************************************
@@ -171,6 +193,20 @@ public final class DeleteAttachmentsProcess
this.service = service;
}
/*******************************************************************************
** The attachments service, created on first use inside a run (QContext exists).
*******************************************************************************/
private AttachmentsService service()
{
if(service == null)
{
service = new AttachmentsService();
}
return (service);
}
/*******************************************************************************
** Count success only after both storage and row deletion succeed.
*******************************************************************************/
@@ -181,7 +217,7 @@ public final class DeleteAttachmentsProcess
{
try
{
service.delete(List.of(row.getValue("id")));
service().delete(List.of(row.getValue("id")));
deleted.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
catch(QException | RuntimeException expected)
@@ -0,0 +1,32 @@
/*
* 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.comments;
import java.time.Instant;
import java.util.List;
/*******************************************************************************
** Thread item; deleted bodies are never returned.
*******************************************************************************/
public record CommentEntry(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<MentionEntry> mentions, List<CommentEntry> replies)
{
}
@@ -0,0 +1,31 @@
/*
* 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.comments;
import java.util.List;
/*******************************************************************************
** A page counts root threads; replies stay grouped under each root.
*******************************************************************************/
public record CommentPage(List<CommentEntry> threads, String nextCursor)
{
}
@@ -0,0 +1,31 @@
/*
* 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.comments;
import java.time.Instant;
/*******************************************************************************
** Prior body and the identity/time of the edit that replaced it.
*******************************************************************************/
public record CommentRevision(Integer id, String body, Instant editedAt, String editedByUserId)
{
}
@@ -0,0 +1,438 @@
/*
* 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.comments;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.RecordCursor;
import com.kingsrook.qbits.collaboration.activity.ActivityWriter;
import com.kingsrook.qbits.collaboration.follow.FollowEntry;
import com.kingsrook.qbits.collaboration.follow.FollowService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.Notifications;
import com.kingsrook.qbits.collaboration.people.PeopleService;
import com.kingsrook.qbits.collaboration.people.PersonEntry;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
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.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Record comments, one-level threads, edit history and recipient-safe mentions.
*******************************************************************************/
public class CommentsService
{
private static final Pattern MENTION = Pattern.compile("@\\[[^\\]\\r\\n]+]\\(user:([^()\\s]+)\\)");
/*******************************************************************************
** Validate all input and the optional reply target before inserting anything.
*******************************************************************************/
public CommentEntry post(String table, Serializable recordId, String body, Integer parentCommentId, String fieldName) throws QException
{
new PeopleService().touch();
ParentRecordAccess.requireFeature(table, recordId, "comment");
String id = FollowService.canonicalId(table, recordId);
Set<String> mentions = parse(body);
Integer rootId = null;
if(parentCommentId != null)
{
QRecord parent = requireComment(parentCommentId);
if(!table.equals(parent.getValueString("tableName")) || !id.equals(parent.getValueString("recordId")))
{
throw (new QNotFoundException("Not found"));
}
rootId = parent.getValueInteger("threadId") == null ? parent.getValueInteger("id") : parent.getValueInteger("threadId");
}
if(fieldName != null && !QContext.getQInstance().getTable(table).getFields().containsKey(fieldName))
{
throw (new QBadRequestException("Unknown field"));
}
Instant now = Instant.now();
QRecord row = CollaborationRecords.insert(CollaborationTables.COMMENT, reference(table, id).withValue("body", body)
.withValue("threadId", rootId).withValue("parentCommentId", rootId).withValue("fieldName", fieldName)
.withValue("authorUserId", PersonalRecords.currentUserId()).withValue("lastActivityAt", now));
bumpRoot(row, now);
Set<String> notified = processMentions(row, mentions);
new FollowService().ensureFollower(PersonalRecords.currentUserId(), table, id, "COMMENTED");
ActivityWriter.write(table, id, "COMMENTED", "Comment added", CollaborationTables.COMMENT, row.getValueString("id"));
notifyFollowers(row, notified);
return (entry(row, List.of()));
}
/*******************************************************************************
** Keyset-page root threads by latest activity; fetch replies oldest first.
*******************************************************************************/
public CommentPage list(String table, Serializable recordId, String cursor, Integer limit) throws QException
{
new PeopleService().touch();
String id = FollowService.canonicalId(table, recordId);
requireLimit(limit);
RecordCursor before = cursor == null ? null : RecordCursor.decode(cursor);
if(before != null && !before.kind().equals("COMMENT"))
{
throw (new QBadRequestException("Invalid comment cursor"));
}
QQueryFilter filter = CollaborationRecords.parent(table, id).withCriteria("threadId", QCriteriaOperator.IS_BLANK)
.withOrderBy(new QFilterOrderBy("lastActivityAt", false)).withOrderBy(new QFilterOrderBy("id", false));
if(before != null)
{
filter.withSubFilter(before.beforeFilter("lastActivityAt", "COMMENT"));
}
List<QRecord> roots = CollaborationRecords.query(CollaborationTables.COMMENT, filter.withLimit(limit + 1));
List<CommentEntry> result = new ArrayList<>();
for(QRecord row : roots.stream().limit(limit).toList())
{
List<CommentEntry> replies = new ArrayList<>();
for(QRecord reply : CollaborationRecords.query(CollaborationTables.COMMENT, CollaborationRecords.parent(table, id)
.withCriteria("threadId", QCriteriaOperator.EQUALS, row.getValue("id"))
.withOrderBy(new QFilterOrderBy("createDate")).withOrderBy(new QFilterOrderBy("id"))))
{
replies.add(entry(reply, List.of()));
}
result.add(entry(row, replies));
}
return (new CommentPage(List.copyOf(result), roots.size() > limit ? key(roots.get(limit - 1)).encode() : null));
}
/*******************************************************************************
** Authors alone can edit; administrators cannot rewrite another author's body.
*******************************************************************************/
public CommentEntry edit(Serializable commentId, String body) throws QException
{
new PeopleService().touch();
QRecord row = requireComment(commentId);
requireAuthor(row);
requireCommentFeature(row);
Set<String> mentions = parse(body);
Instant now = Instant.now();
CollaborationRecords.insert(CollaborationTables.COMMENT_REVISION, reference(row.getValueString("tableName"), row.getValueString("recordId"))
.withValue("commentId", row.getValue("id")).withValue("body", row.getValueString("body")).withValue("editedAt", now)
.withValue("editedByUserId", PersonalRecords.currentUserId()));
CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("id")).withValue("body", body)
.withValue("editedAt", now).withValue("lastActivityAt", now));
row.withValue("body", body).withValue("editedAt", now);
bumpRoot(row, now);
processMentions(row, mentions);
ActivityWriter.write(row.getValueString("tableName"), row.getValueString("recordId"), "COMMENT_EDITED", "Comment edited", CollaborationTables.COMMENT, row.getValueString("id"));
return (entry(row, List.of()));
}
/*******************************************************************************
** Soft delete; moderators still need to see the actual parent.
*******************************************************************************/
public void delete(Serializable commentId) throws QException
{
new PeopleService().touch();
QRecord row = requireComment(commentId);
if(!isAdmin())
{
requireAuthor(row);
requireCommentFeature(row);
}
Instant now = Instant.now();
CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("id"))
.withValue("deletedAt", now).withValue("deletedByUserId", PersonalRecords.currentUserId()).withValue("lastActivityAt", now));
bumpRoot(row, now);
}
/*******************************************************************************
** Resolve only a root thread.
*******************************************************************************/
public void resolve(Serializable commentId) throws QException
{
setResolved(commentId, true);
}
/*******************************************************************************
** Reopen only a root thread.
*******************************************************************************/
public void reopen(Serializable commentId) throws QException
{
setResolved(commentId, false);
}
/*******************************************************************************
** Anyone able to comment can change root resolution state.
*******************************************************************************/
private void setResolved(Serializable commentId, boolean resolved) throws QException
{
new PeopleService().touch();
QRecord row = requireComment(commentId);
requireCommentFeature(row);
if(row.getValue("threadId") != null)
{
throw (new QBadRequestException("Resolve or reopen the root comment."));
}
Instant now = Instant.now();
CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("id"))
.withValue("resolvedAt", resolved ? now : null).withValue("resolvedByUserId", resolved ? PersonalRecords.currentUserId() : null).withValue("lastActivityAt", now));
ActivityWriter.write(row.getValueString("tableName"), row.getValueString("recordId"), resolved ? "COMMENT_RESOLVED" : "COMMENT_REOPENED",
resolved ? "Thread resolved" : "Thread reopened", CollaborationTables.COMMENT, row.getValueString("id"));
}
/*******************************************************************************
** History follows parent read access without requiring edit permission.
*******************************************************************************/
public List<CommentRevision> history(Serializable commentId) throws QException
{
new PeopleService().touch();
QRecord row = requireComment(commentId);
return (CollaborationRecords.query(CollaborationTables.COMMENT_REVISION, new QQueryFilter().withCriteria("commentId", QCriteriaOperator.EQUALS, row.getValue("id"))
.withOrderBy(new QFilterOrderBy("editedAt")).withOrderBy(new QFilterOrderBy("id"))).stream()
.map(revision -> new CommentRevision(revision.getValueInteger("id"), revision.getValueString("body"), revision.getValueInstant("editedAt"), revision.getValueString("editedByUserId"))).toList());
}
/*******************************************************************************
** Rank current participants before other matching active directory users.
*******************************************************************************/
public List<MentionCandidate> mentionCandidates(String table, Serializable recordId, String q) throws QException
{
new PeopleService().touch();
String id = FollowService.canonicalId(table, recordId);
Set<String> participants = new HashSet<>();
new FollowService().followers(table, id).forEach(follower -> participants.add(follower.userId()));
CollaborationRecords.query(CollaborationTables.COMMENT, CollaborationRecords.parent(table, id)).forEach(row -> participants.add(row.getValueString("authorUserId")));
List<PersonEntry> people = new PeopleService().search(q, 50, participants);
List<MentionCandidate> result = new ArrayList<>();
for(PersonEntry person : people)
{
result.add(new MentionCandidate(person, new CollaborationRecordAccessChecker().canRead(person.userId(), table, id)));
}
return (List.copyOf(result));
}
/*******************************************************************************
** Validate before persistence; deduplicate and reject more than fifty mentions.
*******************************************************************************/
private Set<String> parse(String body) throws QException
{
if(body == null || body.isBlank() || body.length() > 20000)
{
throw (new QBadRequestException("Comment body must contain 1 to 20000 characters."));
}
Set<String> users = new LinkedHashSet<>();
Matcher matcher = MENTION.matcher(body);
while(matcher.find())
{
if(matcher.group(1).length() > 255)
{
throw (new QBadRequestException("Mention user ID is too long."));
}
users.add(matcher.group(1));
}
if(users.size() > 50)
{
throw (new QBadRequestException("A comment may mention at most 50 users."));
}
return (users);
}
/*******************************************************************************
** Keep historical mentions so an edit never re-notifies an already mentioned user.
*******************************************************************************/
private Set<String> processMentions(QRecord row, Set<String> users) throws QException
{
Set<String> existing = new HashSet<>();
mentionRows(row).forEach(mention -> existing.add(mention.getValueString("mentionedUserId")));
Set<String> notified = new HashSet<>();
String table = row.getValueString("tableName");
String id = row.getValueString("recordId");
for(String user : users)
{
if(existing.contains(user))
{
continue;
}
boolean visible = new CollaborationRecordAccessChecker().canRead(user, table, id).orElse(false);
QRecord mention = CollaborationRecords.insert(CollaborationTables.MENTION, reference(table, id).withValue("commentId", row.getValue("id"))
.withValue("mentionedUserId", user).withValue("visible", visible));
if(visible)
{
new FollowService().ensureFollower(user, table, id, "MENTIONED");
if(!user.equals(PersonalRecords.currentUserId()))
{
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment on ", mention.getValueInteger("id"));
notified.add(user);
}
}
}
return (notified);
}
/*******************************************************************************
** Mention recipients get one notification; muted and author subscriptions are skipped.
*******************************************************************************/
private void notifyFollowers(QRecord row, Set<String> mentioned) throws QException
{
String author = PersonalRecords.currentUserId();
for(FollowEntry follower : new FollowService().followers(row.getValueString("tableName"), row.getValueString("recordId")))
{
if(!author.equals(follower.userId()) && !mentioned.contains(follower.userId()) && Boolean.TRUE.equals(follower.notifyOnComment())
&& (follower.mutedUntil() == null || !follower.mutedUntil().isAfter(Instant.now())))
{
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on ", null);
}
}
}
/*******************************************************************************
** Delivery re-checks recipient access, including after transaction commit.
*******************************************************************************/
private void notify(QRecord row, String user, CollaborationNotification.Category category, String title, Integer mentionId) throws QException
{
Notifications.send(new CollaborationNotification(user, title
+ new CollaborationRecordAccessChecker().notificationLabel(user, row.getValueString("tableName"), row.getValueString("recordId")), row.getValueString("body"), category,
row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId")))
.withEvent(mentionId == null ? row.getValue("id") : mentionId, PersonalRecords.currentUserId()),
mentionId == null ? null : () -> CollaborationRecords.update(CollaborationTables.MENTION,
new QRecord().withValue("id", mentionId).withValue("notifiedAt", Instant.now())));
}
/*******************************************************************************
** Generic child reference for query security.
*******************************************************************************/
private QRecord reference(String table, String id)
{
return (new QRecord().withValue("tableName", table).withValue("recordId", id).withValue("parentDeleted", false));
}
/*******************************************************************************
** Parent visibility is checked before exposing even ownership metadata.
*******************************************************************************/
private QRecord requireComment(Serializable id) throws QException
{
List<QRecord> rows = CollaborationRecords.query(CollaborationTables.COMMENT, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id));
if(rows.isEmpty() || Boolean.TRUE.equals(rows.getFirst().getValueBoolean("parentDeleted")))
{
throw (new QNotFoundException("Not found"));
}
QRecord row = rows.getFirst();
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
return (row);
}
/*******************************************************************************
** Deleted comments cannot be edited by their author.
*******************************************************************************/
private void requireAuthor(QRecord row) throws QException
{
if(!PersonalRecords.currentUserId().equals(row.getValueString("authorUserId")) || row.getValue("deletedAt") != null)
{
throw (new QPermissionDeniedException("Only the author can edit or delete this comment."));
}
}
/*******************************************************************************
** Shared core record feature permission boundary.
*******************************************************************************/
private void requireCommentFeature(QRecord row) throws QException
{
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "comment");
}
/*******************************************************************************
** Update root ordering whenever a reply changes.
*******************************************************************************/
private void bumpRoot(QRecord row, Instant at) throws QException
{
if(row.getValue("threadId") != null)
{
CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("threadId")).withValue("lastActivityAt", at));
}
}
/*******************************************************************************
** Comment cursor key uses root activity, not creation time.
*******************************************************************************/
private RecordCursor key(QRecord row)
{
return (new RecordCursor(row.getValueInstant("lastActivityAt"), "COMMENT", row.getValueLong("id")));
}
/*******************************************************************************
** Bound public collection responses.
*******************************************************************************/
private void requireLimit(Integer limit) throws QException
{
if(limit == null || limit < 1 || limit > 100)
{
throw (new QBadRequestException("Limit must be between 1 and 100"));
}
}
/*******************************************************************************
** Child rows are safe after comment or parent authorization.
*******************************************************************************/
private List<QRecord> mentionRows(QRecord row) throws QException
{
return (CollaborationRecords.query(CollaborationTables.MENTION, new QQueryFilter().withCriteria("commentId", QCriteriaOperator.EQUALS, row.getValue("id"))));
}
/*******************************************************************************
** Capabilities reflect ownership and the same write permission as mutations.
*******************************************************************************/
private CommentEntry entry(QRecord row, List<CommentEntry> replies) throws QException
{
boolean author = PersonalRecords.currentUserId().equals(row.getValueString("authorUserId"));
boolean deleted = row.getValue("deletedAt") != null;
boolean feature;
try
{
requireCommentFeature(row);
feature = true;
}
catch(QPermissionDeniedException expected)
{
feature = false;
}
List<MentionEntry> mentions = mentionRows(row).stream().map(mention -> new MentionEntry(mention.getValueString("mentionedUserId"), mention.getValueBoolean("visible"))).toList();
return (new CommentEntry(row.getValueInteger("id"), row.getValueInteger("threadId"), row.getValueInteger("parentCommentId"), deleted ? null : row.getValueString("body"),
row.getValueString("fieldName"), row.getValueString("authorUserId"), new PeopleService().displayName(row.getValueString("authorUserId")), row.getValueInstant("createDate"),
row.getValueInstant("editedAt"), deleted, row.getValue("resolvedAt") != null, author && feature && !deleted, !deleted && ((author && feature) || isAdmin()), mentions, replies));
}
/*******************************************************************************
** Moderation is distinct from permission to edit another author's text.
*******************************************************************************/
private boolean isAdmin()
{
return (QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN));
}
}
@@ -0,0 +1,32 @@
/*
* 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.comments;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.people.PersonEntry;
/*******************************************************************************
** Tri-state access is exposed only after the caller can read the parent.
*******************************************************************************/
public record MentionCandidate(PersonEntry person, Optional<Boolean> canRead)
{
}
@@ -0,0 +1,30 @@
/*
* 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.comments;
/*******************************************************************************
** Mention visibility is the recipient access result at processing time.
*******************************************************************************/
public record MentionEntry(String userId, Boolean visible)
{
}
@@ -0,0 +1,202 @@
/*
* 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.customfields;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
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.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
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.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** PostgreSQL indexes use independent autocommit connections, never a row transaction.
*******************************************************************************/
public class BuildFieldIndexes implements BackendStep
{
public static final String NAME = "collaborationBuildFieldIndexes";
/*******************************************************************************
** Always callable as a hidden job; scheduling is explicitly configured by the host.
*******************************************************************************/
public static QProcessMetaData metadata(CollaborationQBitConfig config)
{
var process = new QProcessMetaData().withName(NAME).withLabel("Build custom field indexes").withIsHidden(true)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(FieldSecurity.class)))
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(BuildFieldIndexes.class)));
if(config.getSchedulerName() != null)
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(60).withInitialDelaySeconds(60));
}
return (process);
}
/*******************************************************************************
** Retry pending and failed indexes, leaving completed indexes alone.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
FieldSecurity.requireManage();
for(QRecord definition : CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, List.of("ACTIVE", "ARCHIVED"))
.withCriteria("indexState", QCriteriaOperator.IN, List.of("NONE", "FAILED", "BUILDING"))))
{
build(definition);
}
}
/*******************************************************************************
** The published definition must commit before its index can be built.
*******************************************************************************/
public static void afterCommit(QRecord definition) throws QException
{
if(QContext.getQBackendTransaction() == null)
{
build(definition);
return;
}
var context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
var id = definition.getValue("id");
QContext.getQBackendTransaction().addAfterCommitCallback(() ->
{
try
{
QContext.withTemporaryContext(context, () -> build(FieldDefinitions.get(id)));
}
catch(QException e)
{
throw (new IllegalStateException("Could not build custom field index", e));
}
});
}
/*******************************************************************************
** Record the statement even when the host reserves DDL for migrations.
*******************************************************************************/
public static void build(QRecord definition) throws QException
{
FieldSecurity.requireManage();
QTableMetaData table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
if(!CustomFieldStorage.postgres(table))
{
return;
}
boolean managed = Boolean.TRUE.equals(CollaborationRecords.config().getManagedIndexes());
String ddl = ddl(table, definition, false);
if(ddl == null)
{
state(definition, "NONE", null, null);
return;
}
if(!managed)
{
state(definition, "NONE", "Apply indexDdl through the host migration tooling. Text indexes require pg_trgm for substring search.", ddl);
return;
}
try(var connection = CustomFieldStorage.connection(table))
{
connection.setAutoCommit(true);
boolean trigram;
try(var statement = connection.createStatement(); var result = statement.executeQuery("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')"))
{
result.next();
trigram = result.getBoolean(1);
}
ddl = ddl(table, definition, trigram);
state(definition, "BUILDING", null, ddl);
try(var statement = connection.createStatement())
{
statement.execute(ddl);
}
state(definition, "ARCHIVED".equals(definition.getValueString("status")) ? "NONE" : "READY", null, ddl);
}
catch(Exception e)
{
state(definition, "FAILED", e.getMessage(), ddl);
}
}
/*******************************************************************************
** Generate deterministic safe identifiers; PostgreSQL names are at most 63 bytes.
*******************************************************************************/
public static String ddl(QTableMetaData table, QRecord definition, boolean trigram) throws QException
{
String key = definition.getValueString("name");
if(key == null || !key.matches("[a-z][a-z0-9_]{0,40}"))
{
throw (new QException("Invalid field name"));
}
String index = "cf_" + table.getName() + "_" + key;
if(index.length() > 63)
{
index = index.substring(0, 45) + "_" + Integer.toUnsignedString(index.hashCode(), 16);
}
if("ARCHIVED".equals(definition.getValueString("status")))
{
return ("DROP INDEX CONCURRENTLY IF EXISTS " + CustomFieldStorage.identifier(index));
}
String type = switch(definition.getValueString("type"))
{
case "INTEGER" -> "integer";
case "DECIMAL" -> "numeric";
case "DATE" -> "date";
case "DATE_TIME" -> "timestamptz";
default -> null;
};
boolean searchable = Boolean.TRUE.equals(definition.getValueBoolean("searchable"));
if(!searchable && !(Boolean.TRUE.equals(definition.getValueBoolean("filterable")) && type != null))
{
return (null);
}
String expression = "(qqq_custom_fields->>'" + key + "')";
String prefix = "CREATE INDEX CONCURRENTLY IF NOT EXISTS " + CustomFieldStorage.identifier(index) + " ON " + CustomFieldStorage.sqlTable(table);
if(type == null && searchable && trigram)
{
return (prefix + " USING gin (" + expression + " gin_trgm_ops)");
}
return (prefix + " ((" + expression + (type == null ? "" : "::" + type) + "))");
}
/*******************************************************************************
** Internal diagnostic updates do not recursively refresh the overlay.
*******************************************************************************/
private static void state(QRecord definition, String state, String message, String ddl) throws QException
{
CollaborationRecords.update(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("id", definition.getValue("id"))
.withValue("indexState", state).withValue("statusMessage", message).withValue("indexDdl", ddl));
}
}
@@ -0,0 +1,140 @@
/*
* 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.customfields;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import java.util.WeakHashMap;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
/*******************************************************************************
** Probe once per instance/table; metadata refresh never performs schema DDL.
*******************************************************************************/
public final class CustomFieldStorage
{
private static final Map<QInstance, Map<String, Availability>> CACHE = new WeakHashMap<>();
/*******************************************************************************
** Static backend boundary.
*******************************************************************************/
private CustomFieldStorage()
{
}
/*******************************************************************************
** A visible reason accompanies every fallback.
*******************************************************************************/
public record Availability(boolean json, String reason)
{
}
/*******************************************************************************
** Memory needs no schema. Only PostgreSQL implements the JSON SQL contract.
*******************************************************************************/
public static synchronized Availability availability(QTableMetaData table)
{
var instance = QContext.getQInstance();
return (CACHE.computeIfAbsent(instance, key -> new HashMap<>()).computeIfAbsent(table.getName(), key -> probe(table)));
}
/*******************************************************************************
** Hosts can retry after applying the one-time migration.
*******************************************************************************/
public static synchronized void invalidate(QInstance instance, String table)
{
CACHE.getOrDefault(instance, new HashMap<>()).remove(table);
}
/*******************************************************************************
** Failed probes never install JSON fields that would break ordinary queries.
*******************************************************************************/
private static Availability probe(QTableMetaData table)
{
var backend = QContext.getQInstance().getBackend(table.getBackendName());
if(new MemoryBackendModule().getBackendType().equals(backend.getBackendType()))
{
return (new Availability(true, null));
}
if(!postgres(table))
{
return (new Availability(false, "Backend does not support JsonPathStorage; EAV fields are read-only filters (values are not selected)."));
}
try(Connection connection = connection(table); var statement = connection.createStatement())
{
statement.executeQuery("SELECT qqq_custom_fields FROM " + sqlTable(table) + " LIMIT 0").close();
return (new Availability(true, null));
}
catch(SQLException | IllegalArgumentException e)
{
return (new Availability(false, "JSON column unavailable; EAV fields are read-only filters. " + e.getMessage()));
}
}
/*******************************************************************************
** Match the installed RDBMS metadata, not a backend's arbitrary configured name.
*******************************************************************************/
public static boolean postgres(QTableMetaData table)
{
return (QContext.getQInstance().getBackend(table.getBackendName()) instanceof RDBMSBackendMetaData backend
&& ("postgres".equalsIgnoreCase(backend.getVendor()) || "postgresql".equalsIgnoreCase(backend.getVendor())));
}
/*******************************************************************************
** A fresh backend connection is intentionally outside QContext's transaction.
*******************************************************************************/
public static Connection connection(QTableMetaData table) throws SQLException
{
return (ConnectionManager.getConnection((RDBMSBackendMetaData) QContext.getQInstance().getBackend(table.getBackendName())));
}
/*******************************************************************************
** Quote metadata identifiers; never accept SQL fragments from definitions.
*******************************************************************************/
public static String identifier(String name)
{
if(name == null || name.isBlank() || name.indexOf('\0') >= 0)
{
throw (new IllegalArgumentException("Invalid SQL identifier"));
}
return ("\"" + name.replace("\"", "\"\"") + "\"");
}
/*******************************************************************************
** Backend table names may include a schema qualification.
*******************************************************************************/
public static String sqlTable(QTableMetaData table)
{
String name = table.getBackendDetails() instanceof com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails details ? details.getTableName() : null;
if(name == null)
{
name = table.getName();
}
return (java.util.Arrays.stream(name.split("\\.")).map(CustomFieldStorage::identifier).collect(java.util.stream.Collectors.joining(".")));
}
}
@@ -0,0 +1,68 @@
/*
* 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.customfields;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.collaboration.FeatureSettingsProvider;
import com.kingsrook.qqq.backend.core.actions.values.BasicCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Picker restricted to tables enabled for the custom-field designer.
*******************************************************************************/
public class CustomFieldTablesProvider extends BasicCustomPossibleValueProvider<QTableMetaData, String>
{
/*******************************************************************************
** Resolve code and persisted policy consistently with the overlay.
*******************************************************************************/
@Override
protected List<QTableMetaData> getAllSourceObjects() throws QException
{
var instance = QContext.getQInstance();
var overrides = new FeatureSettingsProvider().getOverrides(instance);
return (instance.getTables().values().stream().filter(table -> !table.getName().startsWith("collaboration_") && table.getPrimaryKeyField() != null
&& FeatureSettingsProvider.enabled(instance, table, RecordFeature.CUSTOM_FIELDS, overrides)).toList());
}
/*******************************************************************************
** Reject stale selections when the feature is disabled.
*******************************************************************************/
@Override
protected QTableMetaData getSourceObject(Serializable id) throws QException
{
return (getAllSourceObjects().stream().filter(table -> table.getName().equals(id)).findFirst().orElse(null));
}
/*******************************************************************************
** Use the metadata label rather than the SQL name.
*******************************************************************************/
@Override
protected QPossibleValue<String> makePossibleValue(QTableMetaData table)
{
return (new QPossibleValue<>(table.getName(), table.getLabel()));
}
}
@@ -0,0 +1,163 @@
/*
* 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.customfields;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.FeatureSettingsProvider;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataProviderInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
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.RuntimeMetaDataContribution;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RuntimeTableMetaDataContribution;
import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueRangeBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
/*******************************************************************************
** ACTIVE definitions become normal fields through the core copy-on-write overlay.
*******************************************************************************/
public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
{
/*******************************************************************************
** Build against incoming feature settings, not the previous resolver snapshot.
*******************************************************************************/
@Override
public RuntimeMetaDataContribution getContribution(QInstance instance) throws QException
{
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(instance);
var result = new RuntimeMetaDataContribution();
var overrides = new FeatureSettingsProvider().getOverrides(instance);
List<QRecord> definitions = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")));
long generation = 1;
for(QRecord definition : definitions)
{
generation = 31 * generation + definition.getValues().hashCode();
var table = instance.getTable(definition.getValueString("tableName"));
if(!"ACTIVE".equals(definition.getValueString("status")) || table == null
|| !FeatureSettingsProvider.enabled(instance, table, RecordFeature.CUSTOM_FIELDS, overrides))
{
continue;
}
var storage = CustomFieldStorage.availability(table);
String storageName = storage.json() ? "JSONB" : "EAV";
if(!storageName.equals(definition.getValueString("storage")) || (!storage.json() && !java.util.Objects.equals(storage.reason(), definition.getValueString("statusMessage"))))
{
CollaborationRecords.update(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("id", definition.getValue("id"))
.withValue("storage", storageName).withValue("statusMessage", storage.reason()));
}
QFieldMetaData field = field(instance, definition, storage.json());
var contribution = result.getTables().computeIfAbsent(table.getName(), key -> new RuntimeTableMetaDataContribution());
if(storage.json())
{
contribution.getFields().add(field);
contribution.getSectionPlacements().put(field.getName(), definition.getValueString("sectionName") == null || definition.getValueString("sectionName").isBlank() ? "customFields" : definition.getValueString("sectionName"));
}
else
{
contribution.getVirtualFields().add((QVirtualFieldMetaData) field);
}
if(Boolean.TRUE.equals(definition.getValueBoolean("searchable")))
{
contribution.getSearchFields().add(field.getName());
}
}
return (result.withGeneration(generation & Long.MAX_VALUE));
}
/*******************************************************************************
** Core supports scalar STRING storage for multi-select, not array operators.
*******************************************************************************/
public static QFieldType type(String type)
{
return (switch(type)
{
case "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK" -> QFieldType.STRING;
default -> QFieldType.valueOf(type);
});
}
/*******************************************************************************
** Preserve classifications and supported validation on the contributed field.
*******************************************************************************/
private QFieldMetaData field(QInstance instance, QRecord definition, boolean json) throws QException
{
String name = "cf_" + definition.getValueString("name");
QFieldType type = type(definition.getValueString("type"));
QFieldMetaData field = json ? new QFieldMetaData(name, type).withBackendStorage(new JsonPathStorage().withColumnName("qqq_custom_fields").withKey(definition.getValueString("name")))
: new QVirtualFieldMetaData(name, type).withIsEditable(false).withIsQueryCriteria(true).withIsQuerySelectable(false)
.withCriteriaExpander(new QCodeReference(FieldCriteriaExpander.class));
field.withLabel(definition.getValueString("label")).withIsRequired(json && Boolean.TRUE.equals(definition.getValueBoolean("required")))
.withMaxLength(definition.getValueInteger("maxLength"));
if(definition.getValue("defaultValue") != null && json)
{
field.withDefaultValue(com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(type, definition.getValue("defaultValue")));
}
if(definition.getValueString("helpText") != null)
{
field.withHelpContent(new QHelpContent().withContentAsText(definition.getValueString("helpText")));
}
if((type == QFieldType.INTEGER || type == QFieldType.DECIMAL) && (definition.getValue("minValue") != null || definition.getValue("maxValue") != null))
{
field.withBehavior(new ValueRangeBehavior().withMinValue(definition.getValueBigDecimal("minValue")).withMaxValue(definition.getValueBigDecimal("maxValue")));
}
if(definition.getValueString("classification") != null)
{
field.withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.valueOf(definition.getValueString("classification"))));
}
switch(definition.getValueString("type"))
{
case "SELECT", "MULTI_SELECT" ->
{
String source = "collaboration_field_options_" + definition.getValueInteger("id");
instance.getPossibleValueSources().put(source, new QPossibleValueSource().withName(source).withType(QPossibleValueSourceType.ENUM)
.withEnumValues(new ArrayList<>(FieldDefinitions.options(definition).values())));
field.withPossibleValueSourceName(source);
if("MULTI_SELECT".equals(definition.getValueString("type")))
{
field.withHelpContent(new QHelpContent().withContentAsText("Stored as a JSON-array STRING. Array membership operators and multi-select editing are not supported by core yet."));
}
}
case "USER" -> field.withPossibleValueSourceName(CollaborationTables.USER);
case "RECORD_LINK" -> field.withPossibleValueSourceName(definition.getValueString("linkTableName"));
default ->
{
}
}
return (field);
}
}
@@ -0,0 +1,110 @@
/*
* 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.customfields;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.tables.query.CriteriaExpansionContext;
import com.kingsrook.qqq.backend.core.actions.tables.query.VirtualFieldCriteriaExpanderInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QSubQuery;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Read-only EAV filters retain the parent's normal query permissions and paging.
*******************************************************************************/
public class FieldCriteriaExpander implements VirtualFieldCriteriaExpanderInterface
{
/*******************************************************************************
** Missing EAV rows count as blank; negative comparisons complement membership.
*******************************************************************************/
@Override
public QQueryFilter expand(QTableMetaData table, QFilterCriteria criteria, CriteriaExpansionContext context) throws QException
{
String name = criteria.getFieldName().substring(3);
var definitions = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter()
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("name", QCriteriaOperator.EQUALS, name)
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
if(definitions.size() != 1)
{
throw (new QException("Custom field is unavailable"));
}
var operator = criteria.getOperator();
boolean negate = operator == QCriteriaOperator.IS_BLANK || operator == QCriteriaOperator.NOT_EQUALS || operator == QCriteriaOperator.NOT_IN;
var positive = switch(operator)
{
case IS_BLANK -> QCriteriaOperator.IS_NOT_BLANK;
case NOT_EQUALS -> QCriteriaOperator.EQUALS;
case NOT_IN -> QCriteriaOperator.IN;
default -> operator;
};
String column = valueColumn(definitions.getFirst().getValueString("type"));
var valueType = context.instance().getTable(CollaborationTables.FIELD_VALUE).getField(column).getType();
var typedValues = criteria.getValues().stream().map(value -> com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(valueType, value)).toList();
QQueryFilter values = new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName())
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false).withCriteria("fieldName", QCriteriaOperator.EQUALS, name)
.withCriteria(new QFilterCriteria(column, positive, typedValues));
return (new QQueryFilter().withCriteria(table.getPrimaryKeyField(), negate ? QCriteriaOperator.NOT_IN : QCriteriaOperator.IN,
new QSubQuery(CollaborationTables.FIELD_VALUE, "recordId", values)));
}
/*******************************************************************************
** Shared typed column mapping for filtering and value counts.
*******************************************************************************/
public static String valueColumn(String type)
{
return (switch(type)
{
case "INTEGER", "DECIMAL" -> "valueNumber";
case "DATE" -> "valueDate";
case "DATE_TIME" -> "valueDateTime";
case "BOOLEAN" -> "valueBoolean";
default -> "valueText";
});
}
/*******************************************************************************
** Core's search callback has no field argument, so combine searchable EAV text.
*******************************************************************************/
@Override
public QQueryFilter expandSearchTerm(QTableMetaData table, String term, com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind kind, CriteriaExpansionContext context) throws QException
{
QQueryFilter result = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR);
for(var definition : CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter()
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")
.withCriteria("searchable", QCriteriaOperator.EQUALS, true)))
{
if("valueText".equals(valueColumn(definition.getValueString("type"))))
{
result.withSubFilter(expand(table, new QFilterCriteria("cf_" + definition.getValueString("name"), QCriteriaOperator.CONTAINS, term), context));
}
}
if(result.getSubFilters() == null || result.getSubFilters().isEmpty())
{
result.withCriteria(table.getPrimaryKeyField(), QCriteriaOperator.IN, java.util.List.of());
}
return (result);
}
}
@@ -0,0 +1,99 @@
/*
* 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.customfields;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.FeatureSettingsCustomizer;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Direct table writes enforce the same designer rules; post hooks refresh metadata.
*******************************************************************************/
public class FieldDefinitionCustomizer extends FeatureSettingsCustomizer
{
/*******************************************************************************
** New definitions always begin as drafts with server-managed lifecycle fields.
*******************************************************************************/
@Override
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean preview) throws QException
{
FieldSecurity.requireManage();
for(QRecord row : records)
{
row.withValue("status", "DRAFT").withValue("indexState", "NONE").withValue("storage", null)
.withValue("indexDdl", null).withValue("statusMessage", null).withValue("createdByUserId", PersonalRecords.currentUserId());
FieldDefinitions.validate(row, null);
}
return (records);
}
/*******************************************************************************
** Merge partial updates before validation and protect derived state from clients.
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
{
FieldSecurity.requireManage();
for(QRecord row : records)
{
QRecord old = FieldDefinitions.get(row.getValue("id"));
QRecord merged = new QRecord(old);
merged.getValues().putAll(row.getValues());
for(String field : List.of("status", "indexState", "storage", "indexDdl", "statusMessage", "createdByUserId"))
{
if(row.getValues().containsKey(field) && !java.util.Objects.equals(row.getValue(field), old.getValue(field)))
{
throw (new QException("Use the field actions to change " + field));
}
}
FieldDefinitions.validate(merged, old);
if(!java.util.Objects.equals(merged.getValue("statusMessage"), old.getValue("statusMessage")))
{
row.setValue("statusMessage", merged.getValue("statusMessage"));
}
}
return (records);
}
/*******************************************************************************
** Published field values must remain addressable; archive instead of deleting.
*******************************************************************************/
@Override
public List<QRecord> preDelete(DeleteInput input, List<QRecord> records, boolean preview) throws QException
{
FieldSecurity.requireManage();
for(QRecord row : records)
{
if(!"DRAFT".equals(FieldDefinitions.get(row.getValue("id")).getValueString("status")))
{
throw (new QException("Only draft definitions can be deleted; archive published fields."));
}
}
return (records);
}
}
@@ -0,0 +1,280 @@
/*
* 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.customfields;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.FeatureSettingsCustomizer;
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.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.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
/*******************************************************************************
** Designer lifecycle and validations shared by direct writes and record actions.
*******************************************************************************/
public final class FieldDefinitions
{
/*******************************************************************************
** Static domain operations.
*******************************************************************************/
private FieldDefinitions()
{
}
/*******************************************************************************
** Managers may address definitions independently of host record read access.
*******************************************************************************/
public static QRecord get(Serializable id) throws QException
{
FieldSecurity.requireManage();
var rows = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id));
if(rows.isEmpty())
{
throw (new QNotFoundException("Not found"));
}
return (rows.getFirst());
}
/*******************************************************************************
** Stable option ids survive label changes. JSON is [{"id":"a","label":"A"}].
*******************************************************************************/
public static Map<String, QPossibleValue<?>> options(QRecord definition) throws QException
{
Map<String, QPossibleValue<?>> result = new LinkedHashMap<>();
String json = definition.getValueString("options");
if(json == null || json.isBlank())
{
return (result);
}
try
{
var array = new ObjectMapper().readTree(json);
if(!array.isArray())
{
throw (new IllegalArgumentException("Expected an array"));
}
for(var option : array)
{
if(!option.path("id").isTextual() || option.path("id").asText().isBlank() || !option.path("label").isTextual()
|| result.putIfAbsent(option.path("id").asText(), new QPossibleValue<>(option.path("id").asText(), option.path("label").asText())) != null)
{
throw (new IllegalArgumentException("Options need unique nonblank string ids and labels"));
}
}
return (result);
}
catch(Exception e)
{
throw (new QException("Invalid field options: expected an array of {id, label} objects", e));
}
}
/*******************************************************************************
** Reject unsafe names, invalid types, missing links and immutable identity edits.
*******************************************************************************/
public static void validate(QRecord definition, QRecord old) throws QException
{
String name = definition.getValueString("name");
var table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
if(name == null || !name.matches("[a-z][a-z0-9_]{0,40}") || table == null || table.getName().startsWith("collaboration_") || table.getPrimaryKeyField() == null)
{
throw (new QException("Choose a host table and a name matching [a-z][a-z0-9_]{0,40}."));
}
if(definition.getValueString("type") == null || !List.of("STRING", "TEXT", "INTEGER", "DECIMAL", "BOOLEAN", "DATE", "DATE_TIME", "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK").contains(definition.getValueString("type")))
{
throw (new QException("Unsupported custom field type"));
}
if(definition.getValueString("label") == null || definition.getValueString("label").isBlank())
{
throw (new QException("A field label is required"));
}
var existing = table.getFields().get("cf_" + name);
if(existing != null && (old == null || "DRAFT".equals(old.getValueString("status")) || !(existing.getBackendStorage() instanceof JsonPathStorage storage && name.equals(storage.getKey()))))
{
throw (new QException("The field name clashes with a code field"));
}
var virtual = table.getVirtualFields() == null ? null : table.getVirtualFields().get("cf_" + name);
if(virtual != null && (old == null || "DRAFT".equals(old.getValueString("status"))))
{
throw (new QException("The field name clashes with a code virtual field"));
}
if(old != null && (!Objects.equals(old.getValue("tableName"), definition.getValue("tableName")) || !Objects.equals(old.getValue("name"), definition.getValue("name"))))
{
throw (new QException("Table and name cannot change after creation"));
}
if(old != null && !"DRAFT".equals(old.getValueString("status")) && !Objects.equals(old.getValue("type"), definition.getValue("type")))
{
throw (new QException("Type changes are only allowed in DRAFT"));
}
String classification = definition.getValueString("classification");
if(classification != null && !List.of("PUBLIC", "CONFIDENTIAL", "PII", "PHI").contains(classification))
{
throw (new QException("Unknown classification"));
}
if(definition.getValueInteger("maxLength") != null && definition.getValueInteger("maxLength") < 1)
{
throw (new QException("Maximum length must be positive"));
}
if(definition.getValueBigDecimal("minValue") != null && definition.getValueBigDecimal("maxValue") != null
&& definition.getValueBigDecimal("minValue").compareTo(definition.getValueBigDecimal("maxValue")) > 0)
{
throw (new QException("Minimum exceeds maximum"));
}
if("RECORD_LINK".equals(definition.getValueString("type")) && QContext.getQInstance().getPossibleValueSource(definition.getValueString("linkTableName")) == null)
{
throw (new QException("The link table needs a possible value source"));
}
if(definition.getValue("defaultValue") != null)
{
try
{
com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(CustomFieldsProvider.type(definition.getValueString("type")), definition.getValue("defaultValue"));
}
catch(RuntimeException e)
{
throw (new QException("Invalid default value for this field type", e));
}
}
var choices = options(definition);
if(old != null && "ACTIVE".equals(old.getValueString("status")))
{
for(String removed : options(old).keySet())
{
if(!choices.containsKey(removed))
{
if(CustomFieldStorage.postgres(table) && CustomFieldStorage.availability(table).json())
{
if(optionUses(old, removed) > 0)
{
throw (new QException("Cannot remove an option that is still used: " + removed));
}
}
else
{
definition.setValue("statusMessage", "Warning: option removal usage checks are only available on PostgreSQL JSON storage.");
}
}
}
}
}
/*******************************************************************************
** Containment values are parameters, including the scalar JSON-array string.
*******************************************************************************/
private static long optionUses(QRecord definition, String option) throws QException
{
var table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
String predicate = "qqq_custom_fields @> ?::jsonb";
boolean multi = "MULTI_SELECT".equals(definition.getValueString("type"));
if(multi)
{
predicate = "(qqq_custom_fields->>?)::jsonb @> ?::jsonb";
}
try(var connection = CustomFieldStorage.connection(table); var statement = connection.prepareStatement("SELECT COUNT(*) FROM " + CustomFieldStorage.sqlTable(table) + " WHERE " + predicate))
{
if(multi)
{
statement.setString(1, definition.getValueString("name"));
statement.setString(2, new ObjectMapper().writeValueAsString(List.of(option)));
}
else
{
statement.setString(1, new ObjectMapper().writeValueAsString(Map.of(definition.getValueString("name"), option)));
}
try(var result = statement.executeQuery())
{
result.next();
return (result.getLong(1));
}
}
catch(Exception e)
{
throw (new QException("Cannot verify option usage", e));
}
}
/*******************************************************************************
** Count affected values for the Archive Review, without loading host records.
*******************************************************************************/
public static long valueCount(QRecord definition) throws QException
{
var table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
String target = CustomFieldStorage.availability(table).json() ? table.getName() : CollaborationTables.FIELD_VALUE;
QQueryFilter filter = target.equals(table.getName()) ? new QQueryFilter().withCriteria("cf_" + definition.getValueString("name"), QCriteriaOperator.IS_NOT_BLANK)
: new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("fieldName", QCriteriaOperator.EQUALS, definition.getValueString("name"))
.withCriteria(FieldCriteriaExpander.valueColumn(definition.getValueString("type")), QCriteriaOperator.IS_NOT_BLANK);
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput(target).withFilter(filter);
input.withPermissionBypass("Field manager counts affected values for Archive Review.");
return (new com.kingsrook.qqq.backend.core.actions.tables.CountAction().execute(input).getCount());
}
/*******************************************************************************
** Revalidate immediately before applying the action; refresh only after commit.
*******************************************************************************/
public static void apply(Serializable id, String operation) throws QException
{
QRecord definition = get(id);
review(definition, operation);
if(!"Rebuild".equals(operation))
{
String status = "Publish".equals(operation) ? "ACTIVE" : "ARCHIVED";
definition.setValue("status", status);
CollaborationRecords.update(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("id", id).withValue("status", status).withValue("indexState", "NONE"));
new FeatureSettingsCustomizer().postUpdate(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withTransaction(QContext.getQBackendTransaction()), List.of(), java.util.Optional.empty());
}
BuildFieldIndexes.afterCommit(definition);
}
/*******************************************************************************
** Review and Apply share precisely the same state transition rules.
*******************************************************************************/
public static void review(QRecord definition, String operation) throws QException
{
FieldSecurity.requireManage();
validate(definition, definition);
if(!List.of("Publish", "Archive", "Rebuild").contains(operation))
{
throw (new QException("Unknown field action"));
}
if("Publish".equals(operation) && new CustomFieldTablesProvider().getPossibleValue(definition.getValueString("tableName")) == null)
{
throw (new QException("CUSTOM_FIELDS must be enabled before publishing"));
}
String expected = "Publish".equals(operation) ? "DRAFT" : "ACTIVE";
if(!expected.equals(definition.getValueString("status")))
{
throw (new QException(operation + " requires " + expected));
}
}
}
@@ -0,0 +1,187 @@
/*
* 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.customfields;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
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.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;
/*******************************************************************************
** Standard streamed Review/Result field actions, never navigation entries.
*******************************************************************************/
public final class FieldProcesses
{
/*******************************************************************************
** Static metadata factory.
*******************************************************************************/
private FieldProcesses()
{
}
/*******************************************************************************
** Bind the action operation into trusted process metadata.
*******************************************************************************/
public static List<QProcessMetaData> metadata()
{
List<QProcessMetaData> processes = new ArrayList<>();
for(String operation : List.of("Publish", "Archive", "Rebuild"))
{
var values = new HashMap<String, Serializable>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.FIELD_DEFINITION);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.FIELD_DEFINITION);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, operation + " selected custom fields.");
processes.add(StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values)
.withName("collaboration" + operation + "Field").withLabel(operation.equals("Rebuild") ? "Rebuild index" : operation + " field")
.withTableName(CollaborationTables.FIELD_DEFINITION)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(FieldSecurity.class))));
}
return (processes);
}
/*******************************************************************************
** Request parameters cannot substitute another lifecycle operation.
*******************************************************************************/
private static String operation(RunBackendStepInput input)
{
return (input.getProcess().getName().substring("collaboration".length()).replace("Field", ""));
}
/*******************************************************************************
** Summary language works for both singular and plural selections.
*******************************************************************************/
private static ProcessSummaryLine line(Status status, String message)
{
var line = new ProcessSummaryLine(status, 0, null);
line.setSingularFutureMessage(message);
line.setPluralFutureMessage(message);
line.setSingularPastMessage(message);
line.setPluralPastMessage(message);
return (line);
}
/*******************************************************************************
** Review validates every row and counts values retained by archive.
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Failed rows are omitted from the load stream with an explicit reason.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(var row : input.getRecords())
{
var id = row.getValue("id");
ProcessSummaryLine summary;
try
{
var definition = FieldDefinitions.get(id);
FieldDefinitions.review(definition, operation(input));
String message = operation(input) + " field " + definition.getValueString("label");
if("Archive".equals(operation(input)))
{
message += "; " + FieldDefinitions.valueCount(definition) + " records have a value (values retained)";
}
summary = line(Status.OK, message);
output.getRecords().add(row);
}
catch(QException e)
{
summary = line(Status.WARNING, "Skipped: " + e.getMessage());
}
summary.incrementCountAndAddPrimaryKey(id);
lines.add(summary);
}
}
/*******************************************************************************
** Streamed pages accumulate an explicit Review summary.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (lines);
}
}
/*******************************************************************************
** Apply rechecks permissions and status, then reports actual outcomes.
*******************************************************************************/
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** One failed field does not prevent other selected fields from completing.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(var row : input.getRecords())
{
ProcessSummaryLine summary;
try
{
FieldDefinitions.apply(row.getValue("id"), operation(input));
summary = line(Status.OK, operation(input) + " completed");
}
catch(QException e)
{
summary = line(Status.ERROR, "Failed: " + e.getMessage());
}
summary.incrementCountAndAddPrimaryKey(row.getValue("id"));
lines.add(summary);
}
}
/*******************************************************************************
** Report completed actions independently of Review predictions.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (lines);
}
}
}
@@ -0,0 +1,70 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.customfields;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.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.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Definitions are readable with view and writable by field managers or admins.
*******************************************************************************/
public class FieldSecurity implements CustomPermissionChecker
{
/*******************************************************************************
** Processes and writes require management authority.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput || metadata instanceof QProcessMetaData)
{
requireManage();
return;
}
var session = QContext.getQSession();
if(session == null || (!session.hasPermission(CollaborationPermissions.VIEW) && !session.hasPermission(CollaborationPermissions.ADMIN)))
{
throw (new QPermissionDeniedException("This needs collaboration.view."));
}
}
/*******************************************************************************
** Services recheck permission, including at the load step.
*******************************************************************************/
public static void requireManage() throws QPermissionDeniedException
{
var session = QContext.getQSession();
if(!(session instanceof QSystemUserSession) && (session == null || (!session.hasPermission(CollaborationPermissions.FIELDS_MANAGE) && !session.hasPermission(CollaborationPermissions.ADMIN))))
{
throw (new QPermissionDeniedException("This needs collaboration.fields.manage."));
}
}
}
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more