From be1982a0b19195ba6fcc77648e2ba7de5b8017db Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 17:28:53 -0500 Subject: [PATCH 01/12] 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 --- CHANGELOG.md | 17 +- README.md | 102 +- pom.xml | 9 +- .../CollaborationQBitConfig.java | 36 + .../CollaborationQBitProducer.java | 4 + .../qbits/collaboration/RecordCursor.java | 3 +- .../qbits/collaboration/TargetResolver.java | 8 +- .../collaboration/api/ApplyTagsSpec.java | 86 + .../collaboration/api/CollaborationApi.java | 89 + .../api/CollaborationJsonSpec.java | 367 +++ .../api/CollaborationSchemas.java | 716 +++++ .../collaboration/api/CollaborationViews.java | 158 ++ .../collaboration/api/CommentHistorySpec.java | 74 + .../collaboration/api/CreateTagSpec.java | 84 + .../api/DeleteAttachmentSpec.java | 73 + .../collaboration/api/DeleteCommentSpec.java | 73 + .../collaboration/api/DeleteFollowSpec.java | 74 + .../api/DownloadAttachmentSpec.java | 73 + .../collaboration/api/EditCommentSpec.java | 84 + .../collaboration/api/GetFollowSpec.java | 74 + .../collaboration/api/GetPersonSpec.java | 72 + .../collaboration/api/GetTimelineSpec.java | 74 + .../api/ListAttachmentsSpec.java | 74 + .../collaboration/api/ListCommentsSpec.java | 74 + .../collaboration/api/ListFavoritesSpec.java | 86 + .../collaboration/api/ListFollowersSpec.java | 74 + .../collaboration/api/ListPeopleSpec.java | 74 + .../collaboration/api/ListRecentsSpec.java | 74 + .../collaboration/api/ListRecordTagsSpec.java | 74 + .../qbits/collaboration/api/ListTagsSpec.java | 73 + .../api/MentionCandidatesSpec.java | 74 + .../collaboration/api/OrderFavoritesSpec.java | 85 + .../api/PatchAttachmentSpec.java | 84 + .../collaboration/api/PostCommentSpec.java | 84 + .../api/PreviewAttachmentSpec.java | 73 + .../collaboration/api/PutFollowSpec.java | 85 + .../collaboration/api/RecordRecentSpec.java | 85 + .../collaboration/api/ReopenCommentSpec.java | 73 + .../collaboration/api/ResolveCommentSpec.java | 73 + .../collaboration/api/StarFavoriteSpec.java | 85 + .../collaboration/api/UnstarFavoriteSpec.java | 85 + .../api/UploadAttachmentSpec.java | 186 ++ .../attachments/AttachmentsService.java | 16 +- .../comments/CommentsService.java | 17 +- .../favorites/FavoritesService.java | 5 +- .../collaboration/people/PeopleService.java | 21 +- .../qbits/collaboration/tags/TagsService.java | 9 +- .../timeline/TimelineService.java | 7 +- .../openapi/qqq-v1/collaboration.yaml | 2374 +++++++++++++++++ .../CollaborationApiContractTest.java | 81 + .../CollaborationApiHttpTest.java | 430 +++ .../ConversationServicesTest.java | 4 +- .../collaboration/TimelineServiceTest.java | 2 +- 53 files changed, 6948 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ApplyTagsSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationJsonSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CommentHistorySpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CreateTagSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/DeleteAttachmentSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/DeleteCommentSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/DeleteFollowSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/DownloadAttachmentSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/EditCommentSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/GetFollowSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/GetPersonSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/GetTimelineSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListAttachmentsSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListCommentsSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListFavoritesSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListFollowersSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListPeopleSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListRecentsSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListRecordTagsSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListTagsSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/MentionCandidatesSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/OrderFavoritesSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/PatchAttachmentSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/PostCommentSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/PreviewAttachmentSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/PutFollowSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/RecordRecentSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ReopenCommentSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ResolveCommentSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/StarFavoriteSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/UnstarFavoriteSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/UploadAttachmentSpec.java create mode 100644 src/main/resources/openapi/qqq-v1/collaboration.yaml create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 707495d..2911694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Added +- Collaboration UI API extension `1.0.0`: 31 typed operations for favorites, recents, attachments, tags, comments, mentions, follow, timeline and people 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 + +- 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-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. @@ -17,12 +29,12 @@ All notable changes are documented here, following [Keep a Changelog](https://ke - 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; endpoint publication deferred to #1128. +- 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. @@ -31,4 +43,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. diff --git a/README.md b/README.md index cc42043..dbe2924 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/ ## Requirements - Java 21. -- QQQ 4.1.0-RC.5 or newer compatible 4.1 release. The build pins RC.5. +- QQQ `4.1.0-SNAPSHOT` installed from develop, including UI API extension point #1128. The build will move to `4.1.0-RC.6` once released; middleware Javalin is a compile dependency. - 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. @@ -99,7 +99,6 @@ List recents = RecentsService.list(50); - Hrefs mirror Next's accepted flat routes: `/app//`, `/app/
//`, `/app//`, and `/app/
/savedView//`. 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 @@ -129,7 +128,7 @@ RC.5 has no PRE_QUERY customizer. `AttachmentSecurity` uses its custom permissio **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 and realtime events remain future integrations. Upload/delete now write FILE_ADDED/FILE_REMOVED activity. The table declares `fileName`/`description` search fields and uses normal QQQ writes, so host-configured record-change/ESB listeners receive events; this qbit does not install an ESB publication or Quick Search provider. +After a parent deletion commits, trusted host code can call `new AttachmentCleanup().onParentsDeleted(table, ids)`. It deletes objects and rows in batches and is retryable. It cannot check an already-deleted parent and must never be exposed as a user endpoint. 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. @@ -177,7 +176,7 @@ Classes live in the `comments`, `follow`, `timeline`, `people` and `notify` pack 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 remain deferred to [QRun-IO/qqq#1128](https://github.com/QRun-IO/qqq/pull/1128). 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. +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 @@ -203,7 +202,7 @@ UI API endpoints remain deferred to [QRun-IO/qqq#1128](https://github.com/QRun-I 2. For PostgreSQL, generate `CollaborationSchema.postgresScript(config)` and apply it with the host's migration tool. It creates tables and unique indexes, never executes DDL, and does not migrate existing schemas. QQQ owns date/default values on writes. 3. Grant `collaboration.view` and `collaboration.admin` as appropriate; wire the host's audit persistence for FIELD audit events. 4. Validate the assembled instance. Navigate to Collaboration for Comments, Attachments, Activity (admins), Feature Settings and People. People populate as signed-in users call collaboration services. -5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. Favorites, recents, attachments, comments, follow and timeline services can be used now. This phase installs no endpoints or host-table overlays; scheduled work is opt-in. +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 @@ -281,3 +280,96 @@ share a name, the global tag wins (use IDs to disambiguate). phase does not register a virtual filter field, core expander, `QSubQuery`, tag column filler, or `#tag`/host-record quick-search provider. The standalone translator deliberately imports none of those unreleased types. + + +## 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. diff --git a/pom.xml b/pom.xml index 7cd418d..8962254 100644 --- a/pom.xml +++ b/pom.xml @@ -76,6 +76,10 @@ com.kingsrook.qqq qqq-backend-core + + com.kingsrook.qqq + qqq-middleware-javalin + org.junit.jupiter junit-jupiter @@ -173,14 +177,15 @@ - + qqq-snapshot true - 4.1.0-RC.5 + + 4.1.0-SNAPSHOT diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java index ef02284..790bfdb 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java @@ -49,11 +49,47 @@ public class CollaborationQBitConfig implements QBitConfig 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 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); + } + /******************************************************************************* diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java index 3ebf4aa..8f47687 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java @@ -158,6 +158,10 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java new file mode 100644 index 0000000..c124145 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java @@ -0,0 +1,89 @@ +/* + * 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 and people") + .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(UploadAttachmentSpec.class)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationJsonSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationJsonSpec.java new file mode 100644 index 0000000..923e71f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationJsonSpec.java @@ -0,0 +1,367 @@ +/* + * 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 defineAdditionalBasicResponses() + { + return (errors()); + } + + /******************************************************************************* + ** Shared error contract, including the multipart operation. + *******************************************************************************/ + static List 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 parameters(String... queries) + { + return (parametersFor(defineBasicOperation().getPath(), queries)); + } + + /******************************************************************************* + ** Parameter descriptions are shared with multipart upload. + *******************************************************************************/ + static List parametersFor(String path, String... queries) + { + List 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 "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 : Type.STRING))); + } + + /******************************************************************************* + ** Reject malformed types instead of silently coercing client values. + *******************************************************************************/ + protected static T body(JsonInput input, Class 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 Page page(JsonInput input, List 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(List 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 ids(List 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 folders(Map values) throws QBadRequestException + { + Map result = new LinkedHashMap<>(); + if(values != null) + { + for(Map.Entry 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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java new file mode 100644 index 0000000..839454f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java @@ -0,0 +1,716 @@ +/* + * 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) 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)))))); + } + } + + /******************************************************************************* + ** 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 mentions, List 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) 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)))))); + } + } + + /******************************************************************************* + ** A subscription to record changes and comments. + *******************************************************************************/ + public record CollaborationFollowV1(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil) 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)))))); + } + } + + /******************************************************************************* + ** 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 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 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 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 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 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 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 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 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 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 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 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 ids, Map 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 add, List 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 components() + { + Map schemas = new TreeMap<>(); + schemas.put("CollaborationFavoriteV1", new CollaborationFavoriteV1(null, null, null, null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationAttachmentV1", new CollaborationAttachmentV1(null, null, null, null, null, null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationTagV1", new CollaborationTagV1(null, null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationPersonV1", new CollaborationPersonV1(null, null, null, null).toSchema()); + schemas.put("CollaborationMentionV1", new CollaborationMentionV1(null, null).toSchema()); + schemas.put("CollaborationMentionCandidateV1", new CollaborationMentionCandidateV1(null, null).toSchema()); + schemas.put("CollaborationCommentV1", new CollaborationCommentV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationCommentRevisionV1", new CollaborationCommentRevisionV1(null, null, null, null).toSchema()); + schemas.put("CollaborationFollowV1", new CollaborationFollowV1(null, null, null, null, null, null).toSchema()); + schemas.put("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()); + return (schemas); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java new file mode 100644 index 0000000..f524528 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java @@ -0,0 +1,158 @@ +/* + * 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.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.PersonEntry; +import com.kingsrook.qbits.collaboration.tags.TagEntry; +import com.kingsrook.qbits.collaboration.timeline.TimelineDetail; +import com.kingsrook.qbits.collaboration.timeline.TimelineItem; + + +/******************************************************************************* + ** 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())); + } + + /******************************************************************************* + ** Project attachment display data. + *******************************************************************************/ + static CollaborationAttachmentV1 attachment(AttachmentEntry value) + { + return (new CollaborationAttachmentV1( + value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete())); + } + + /******************************************************************************* + ** 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 CollaborationCommentRevisionV1 commentRevision(CommentRevision value) + { + return (new CollaborationCommentRevisionV1( + value.id(), value.body(), value.editedAt(), value.editedByUserId())); + } + + /******************************************************************************* + ** Project follow display data. + *******************************************************************************/ + static CollaborationFollowV1 follow(FollowEntry value) + { + return (new CollaborationFollowV1( + value.id(), value.userId(), value.reason(), value.notifyOnChange(), value.notifyOnComment(), value.mutedUntil())); + } + + /******************************************************************************* + ** 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"))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CommentHistorySpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CommentHistorySpec.java new file mode 100644 index 0000000..568aff1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CommentHistorySpec.java @@ -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 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(page.items().stream().map(CollaborationViews::commentRevision).toList(), page.nextCursor())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CreateTagSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CreateTagSpec.java new file mode 100644 index 0000000..92351c6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CreateTagSpec.java @@ -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 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()))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/DeleteAttachmentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/DeleteAttachmentSpec.java new file mode 100644 index 0000000..5da096c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/DeleteAttachmentSpec.java @@ -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 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/DeleteCommentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/DeleteCommentSpec.java new file mode 100644 index 0000000..967db2c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/DeleteCommentSpec.java @@ -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 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/DeleteFollowSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/DeleteFollowSpec.java new file mode 100644 index 0000000..dbb61ef --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/DeleteFollowSpec.java @@ -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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/DownloadAttachmentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/DownloadAttachmentSpec.java new file mode 100644 index 0000000..7f42cfc --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/DownloadAttachmentSpec.java @@ -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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/EditCommentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/EditCommentSpec.java new file mode 100644 index 0000000..ef576fa --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/EditCommentSpec.java @@ -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 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()))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/GetFollowSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/GetFollowSpec.java new file mode 100644 index 0000000..b92dd40 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/GetFollowSpec.java @@ -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 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.map(CollaborationViews::follow).orElse(null))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/GetPersonSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/GetPersonSpec.java new file mode 100644 index 0000000..5bb788a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/GetPersonSpec.java @@ -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 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")))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/GetTimelineSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/GetTimelineSpec.java new file mode 100644 index 0000000..70cb5c6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/GetTimelineSpec.java @@ -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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListAttachmentsSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListAttachmentsSpec.java new file mode 100644 index 0000000..5defb05 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListAttachmentsSpec.java @@ -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 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(page.items().stream().map(CollaborationViews::attachment).toList(), page.nextCursor())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListCommentsSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListCommentsSpec.java new file mode 100644 index 0000000..06d4a76 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListCommentsSpec.java @@ -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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListFavoritesSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListFavoritesSpec.java new file mode 100644 index 0000000..0b86eb5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListFavoritesSpec.java @@ -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 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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListFollowersSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListFollowersSpec.java new file mode 100644 index 0000000..ab3a306 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListFollowersSpec.java @@ -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 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(page.items().stream().map(CollaborationViews::follow).toList(), page.nextCursor())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListPeopleSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListPeopleSpec.java new file mode 100644 index 0000000..33b06df --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListPeopleSpec.java @@ -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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListRecentsSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListRecentsSpec.java new file mode 100644 index 0000000..3bec97e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListRecentsSpec.java @@ -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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListRecordTagsSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListRecordTagsSpec.java new file mode 100644 index 0000000..e7c8c86 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListRecordTagsSpec.java @@ -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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListTagsSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListTagsSpec.java new file mode 100644 index 0000000..73e6361 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListTagsSpec.java @@ -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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/MentionCandidatesSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/MentionCandidatesSpec.java new file mode 100644 index 0000000..0ea9080 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/MentionCandidatesSpec.java @@ -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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/OrderFavoritesSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/OrderFavoritesSpec.java new file mode 100644 index 0000000..768ecae --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/OrderFavoritesSpec.java @@ -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 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/PatchAttachmentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/PatchAttachmentSpec.java new file mode 100644 index 0000000..9f9a33d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/PatchAttachmentSpec.java @@ -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 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()))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/PostCommentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/PostCommentSpec.java new file mode 100644 index 0000000..0e399b3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/PostCommentSpec.java @@ -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 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()))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/PreviewAttachmentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/PreviewAttachmentSpec.java new file mode 100644 index 0000000..632dff3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/PreviewAttachmentSpec.java @@ -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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/PutFollowSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/PutFollowSpec.java new file mode 100644 index 0000000..45dc51b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/PutFollowSpec.java @@ -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 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())))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/RecordRecentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/RecordRecentSpec.java new file mode 100644 index 0000000..ae281bd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/RecordRecentSpec.java @@ -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 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ReopenCommentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ReopenCommentSpec.java new file mode 100644 index 0000000..833a033 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ReopenCommentSpec.java @@ -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 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ResolveCommentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ResolveCommentSpec.java new file mode 100644 index 0000000..ab813eb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ResolveCommentSpec.java @@ -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 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/StarFavoriteSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/StarFavoriteSpec.java new file mode 100644 index 0000000..08c5016 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/StarFavoriteSpec.java @@ -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 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/UnstarFavoriteSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/UnstarFavoriteSpec.java new file mode 100644 index 0000000..5139e4c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/UnstarFavoriteSpec.java @@ -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 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/UploadAttachmentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/UploadAttachmentSpec.java new file mode 100644 index 0000000..a5adcfb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/UploadAttachmentSpec.java @@ -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 +{ + /******************************************************************************* + ** 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 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 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 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 + { + /******************************************************************************* + ** 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)); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java index 24103ba..3acb0eb 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java @@ -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()); } /******************************************************************************* @@ -102,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; @@ -119,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); @@ -373,18 +375,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); } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java index 4fadf33..9076086 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java @@ -45,6 +45,7 @@ import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; import com.kingsrook.qbits.collaboration.security.OtherUserAccess; 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; @@ -81,7 +82,7 @@ public class CommentsService } if(fieldName != null && !QContext.getQInstance().getTable(table).getFields().containsKey(fieldName)) { - throw (new QException("Unknown field")); + throw (new QBadRequestException("Unknown field")); } Instant now = Instant.now(); QRecord row = CollaborationRecords.insert(CollaborationTables.COMMENT, reference(table, id).withValue("body", body) @@ -106,7 +107,7 @@ public class CommentsService RecordCursor before = cursor == null ? null : RecordCursor.decode(cursor); if(before != null && !before.kind().equals("COMMENT")) { - throw (new QException("Invalid comment cursor")); + 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)); @@ -197,7 +198,7 @@ public class CommentsService requireCommentFeature(row); if(row.getValue("threadId") != null) { - throw (new QException("Resolve or reopen the root comment.")); + throw (new QBadRequestException("Resolve or reopen the root comment.")); } Instant now = Instant.now(); CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("id")) @@ -244,7 +245,7 @@ public class CommentsService { if(body == null || body.isBlank() || body.length() > 20000) { - throw (new QException("Comment body must contain 1 to 20000 characters.")); + throw (new QBadRequestException("Comment body must contain 1 to 20000 characters.")); } Set users = new LinkedHashSet<>(); Matcher matcher = MENTION.matcher(body); @@ -252,13 +253,13 @@ public class CommentsService { if(matcher.group(1).length() > 255) { - throw (new QException("Mention user ID is too long.")); + throw (new QBadRequestException("Mention user ID is too long.")); } users.add(matcher.group(1)); } if(users.size() > 50) { - throw (new QException("A comment may mention at most 50 users.")); + throw (new QBadRequestException("A comment may mention at most 50 users.")); } return (users); } @@ -388,9 +389,9 @@ public class CommentsService *******************************************************************************/ private void requireLimit(Integer limit) throws QException { - if(limit == null || limit < 1 || limit > 50) + if(limit == null || limit < 1 || limit > 100) { - throw (new QException("Limit must be between 1 and 50")); + throw (new QBadRequestException("Limit must be between 1 and 100")); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java b/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java index 4bd4990..39a0154 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java @@ -31,6 +31,7 @@ import java.util.Set; import com.kingsrook.qbits.collaboration.PersonalRecords; import com.kingsrook.qbits.collaboration.TargetResolver; import com.kingsrook.qbits.collaboration.model.CollaborationTables; +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.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; @@ -123,13 +124,13 @@ public final class FavoritesService Set owned = new HashSet<>(rows.stream().map(row -> row.getValueInteger("id")).toList()); if(ids == null || folders == null || new HashSet<>(ids).size() != ids.size() || !owned.containsAll(ids) || !owned.containsAll(folders.keySet())) { - throw (new QException("Invalid favorite ids")); + throw (new QBadRequestException("Invalid favorite ids")); } for(String folder : folders.values()) { if(folder != null && folder.length() > 255) { - throw (new QException("Folder is too long")); + throw (new QBadRequestException("Folder is too long")); } } List order = new ArrayList<>(ids); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java b/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java index 4fb05d0..ed93b95 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java @@ -30,6 +30,7 @@ import com.kingsrook.qbits.collaboration.CollaborationRecords; import com.kingsrook.qbits.collaboration.PersonalRecords; import com.kingsrook.qbits.collaboration.model.CollaborationTables; 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.model.actions.tables.query.CriteriaOption; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; @@ -112,9 +113,9 @@ public class PeopleService public List search(String q, Integer limit, Set preferredUserIds) throws QException { PersonalRecords.currentUserId(); - if(limit == null || limit < 1 || limit > 50) + if(limit == null || limit < 1 || limit > 100) { - throw (new QException("Limit must be between 1 and 50")); + throw (new QBadRequestException("Limit must be between 1 and 100")); } List rows = new ArrayList<>(); if(!preferredUserIds.isEmpty()) @@ -159,4 +160,20 @@ public class PeopleService List rows = CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, userId)); return (rows.isEmpty() || rows.getFirst().getValueString("displayName") == null ? userId : rows.getFirst().getValueString("displayName")); } + + /******************************************************************************* + ** Look up one active person with the same signed-in boundary as search. + *******************************************************************************/ + public PersonEntry get(String userId) throws QException + { + touch(); + List rows = CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter() + .withCriteria("userId", QCriteriaOperator.EQUALS, userId).withCriteria("active", QCriteriaOperator.EQUALS, true).withLimit(1)); + if(rows.isEmpty()) + { + throw (new com.kingsrook.qqq.backend.core.exceptions.QNotFoundException("Not found")); + } + QRecord row = rows.getFirst(); + return (new PersonEntry(row.getValueString("userId"), row.getValueString("displayName"), row.getValueString("email"), row.getValueInstant("lastSeenAt"))); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java index 5527992..da3b710 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java @@ -36,6 +36,7 @@ import com.kingsrook.qbits.collaboration.model.CollaborationTables; import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; import com.kingsrook.qqq.backend.core.context.CapturedContext; 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; @@ -63,7 +64,7 @@ public class TagsService String name = label == null ? "" : label.strip().toLowerCase(Locale.ROOT).replaceAll("(?U)\\s+", " ").strip(); if(name.isEmpty() || name.length() > 60) { - throw (new QException("Tag names must contain 1 to 60 characters.")); + throw (new QBadRequestException("Tag names must contain 1 to 60 characters.")); } return (name); } @@ -185,7 +186,7 @@ public class TagsService if(row.getValue("mergedIntoTagId") != null || (!Boolean.TRUE.equals(row.getValueBoolean("approved")) && !PersonalRecords.currentUserId().equals(row.getValueString("createdByUserId")) && !canManage())) { - throw (new QException("Tag name is already reserved.")); + throw (new QBadRequestException("Tag name is already reserved.")); } return (entry(row)); } @@ -235,7 +236,7 @@ public class TagsService { if(!createMissing || value instanceof Number || !mode(table).equals("FREE")) { - throw (new QException("Pick an approved tag (or suggest one)")); + throw (new QBadRequestException("Pick an approved tag (or suggest one)")); } row = requireTag(create(table, value.toString(), null).id()); } @@ -245,7 +246,7 @@ public class TagsService } if(!Boolean.TRUE.equals(row.getValueBoolean("approved"))) { - throw (new QException("Pick an approved tag (or suggest one)")); + throw (new QBadRequestException("Pick an approved tag (or suggest one)")); } result.put(row.getValueInteger("id"), row); } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java index 7dd794b..bf86b9e 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java @@ -37,6 +37,7 @@ import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMeta import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; 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.QPermissionDeniedException; import com.kingsrook.qqq.backend.core.model.actions.metadata.personalization.TableMetaDataPersonalizerInput; @@ -61,14 +62,14 @@ public class TimelineService public TimelinePage timeline(String table, Serializable recordId, String beforeCursor, Integer limit, Set types) throws QException { String id = FollowService.canonicalId(table, recordId); - if(limit == null || limit < 1 || limit > 50) + if(limit == null || limit < 1 || limit > 100) { - throw (new QException("Limit must be between 1 and 50")); + throw (new QBadRequestException("Limit must be between 1 and 100")); } RecordCursor before = beforeCursor == null ? null : RecordCursor.decode(beforeCursor); if(before != null && before.kind().equals("COMMENT")) { - throw (new QException("Invalid timeline cursor")); + throw (new QBadRequestException("Invalid timeline cursor")); } List items = new ArrayList<>(); boolean all = types == null || types.isEmpty(); diff --git a/src/main/resources/openapi/qqq-v1/collaboration.yaml b/src/main/resources/openapi/qqq-v1/collaboration.yaml new file mode 100644 index 0000000..d209c2a --- /dev/null +++ b/src/main/resources/openapi/qqq-v1/collaboration.yaml @@ -0,0 +1,2374 @@ +components: + schemas: + CollaborationAttachmentListV1: + description: "Attachments, newest first." + properties: + items: + description: "Attachments, newest first." + items: + $ref: "#/components/schemas/CollaborationAttachmentV1" + nullable: true + type: "array" + nextCursor: + description: "Opaque continuation token; null when finished or the service\ + \ has no continuation support." + nullable: true + type: "string" + type: "object" + CollaborationAttachmentPatchRequestV1: + description: "Change attachment display metadata." + properties: + description: + description: "Optional description; empty clears, null leaves unchanged." + nullable: true + type: "string" + fileName: + description: "Optional new display filename; null leaves unchanged." + nullable: true + type: "string" + type: "object" + CollaborationAttachmentUploadRequestV1: + description: "Multipart upload with exactly one file part named file." + properties: + description: + description: "Optional description." + nullable: true + type: "string" + file: + description: "Required file content." + format: "binary" + nullable: true + type: "string" + type: "object" + CollaborationAttachmentV1: + description: "An attachment visible through its parent record; no storage coordinates\ + \ are exposed." + properties: + canDelete: + description: "Whether the caller may change or delete this attachment." + nullable: true + type: "boolean" + contentType: + description: "MIME content type." + nullable: true + type: "string" + createDate: + description: "Upload time." + format: "date-time" + nullable: true + type: "string" + createdByUserId: + description: "Uploader identity." + nullable: true + type: "string" + description: + description: "Optional description." + nullable: true + type: "string" + fileName: + description: "Safe display filename." + nullable: true + type: "string" + id: + description: "Attachment identifier." + nullable: true + type: "integer" + recordId: + description: "Canonical parent record key." + nullable: true + type: "string" + sha256: + description: "SHA-256 content digest." + nullable: true + type: "string" + sizeBytes: + description: "Stored byte count." + nullable: true + type: "integer" + tableName: + description: "Parent table metadata name." + nullable: true + type: "string" + type: "object" + CollaborationCommentEditRequestV1: + description: "Replace the caller's comment body." + properties: + body: + description: "Required replacement body, 1 to 20000 characters." + nullable: true + type: "string" + type: "object" + CollaborationCommentHistoryV1: + description: "Previous bodies in edit order." + properties: + items: + description: "Previous bodies in edit order." + items: + $ref: "#/components/schemas/CollaborationCommentRevisionV1" + nullable: true + type: "array" + nextCursor: + description: "Opaque continuation token; null when finished or the service\ + \ has no continuation support." + nullable: true + type: "string" + type: "object" + CollaborationCommentPageV1: + description: "Root threads by latest activity, with replies." + properties: + nextCursor: + description: "Opaque continuation token; null when finished or the service\ + \ has no continuation support." + nullable: true + type: "string" + threads: + description: "Root threads by latest activity, with replies." + items: + $ref: "#/components/schemas/CollaborationCommentV1" + nullable: true + type: "array" + type: "object" + CollaborationCommentPostRequestV1: + description: "Post a root comment or one-level reply." + properties: + body: + description: "Required body, 1 to 20000 characters; mentions use @[Name](user:id)." + nullable: true + type: "string" + fieldName: + description: "Optional existing parent field name." + nullable: true + type: "string" + parentCommentId: + description: "Optional comment to reply to." + nullable: true + type: "integer" + type: "object" + CollaborationCommentRevisionV1: + description: "A previous comment body." + properties: + body: + description: "Previous body text." + nullable: true + type: "string" + editedAt: + description: "Time the body was replaced." + format: "date-time" + nullable: true + type: "string" + editedByUserId: + description: "Editor identity." + nullable: true + type: "string" + id: + description: "Revision identifier." + nullable: true + type: "integer" + type: "object" + CollaborationCommentV1: + description: "A comment or reply; deleted comments retain their position without\ + \ body text." + properties: + authorDisplayName: + description: "Author display name." + nullable: true + type: "string" + authorUserId: + description: "Author identity." + nullable: true + type: "string" + body: + description: "Plain text with mention tokens; null for deleted comments." + nullable: true + type: "string" + canDelete: + description: "Caller may delete this comment." + nullable: true + type: "boolean" + canEdit: + description: "Caller may edit the body." + nullable: true + type: "boolean" + createdAt: + description: "Creation time." + format: "date-time" + nullable: true + type: "string" + deleted: + description: "Whether the comment is soft deleted." + nullable: true + type: "boolean" + editedAt: + description: "Last edit time, if edited." + format: "date-time" + nullable: true + type: "string" + fieldName: + description: "Optional field being discussed." + nullable: true + type: "string" + id: + description: "Comment identifier." + nullable: true + type: "integer" + mentions: + description: "Mention recipients." + items: + $ref: "#/components/schemas/CollaborationMentionV1" + nullable: true + type: "array" + parentCommentId: + description: "Root comment for a reply; null on roots." + nullable: true + type: "integer" + replies: + description: "Replies in oldest-first order; empty on replies." + items: + $ref: "#/components/schemas/CollaborationCommentV1" + nullable: true + type: "array" + resolved: + description: "Whether the thread is resolved." + nullable: true + type: "boolean" + threadId: + description: "Root thread identifier; null on roots." + nullable: true + type: "integer" + type: "object" + CollaborationDownloadV1: + description: "Temporary access to file bytes. Clients follow url." + properties: + url: + description: "Presigned URL, valid for five minutes. Clients follow url;\ + \ never persist it." + nullable: true + type: "string" + type: "object" + CollaborationErrorV1: + description: "Standard QQQ error response." + properties: + error: + description: "Human-readable reason the operation failed." + nullable: true + type: "string" + type: "object" + CollaborationFavoriteListV1: + description: "Ordered personal favorites." + properties: + items: + description: "Ordered personal favorites." + items: + $ref: "#/components/schemas/CollaborationFavoriteV1" + nullable: true + type: "array" + nextCursor: + description: "Opaque continuation token; null when finished or the service\ + \ has no continuation support." + nullable: true + type: "string" + type: "object" + CollaborationFavoriteOrderRequestV1: + description: "Move selected favorites first and optionally assign folders." + properties: + folders: + additionalProperties: + nullable: true + type: "string" + description: "Optional map from favorite identifier to folder; null values\ + \ clear folders." + nullable: true + type: "object" + ids: + description: "Required ordered favorite identifiers, with no duplicates." + items: + type: "integer" + nullable: true + type: "array" + type: "object" + CollaborationFavoriteV1: + description: "A saved navigation target, with availability rechecked for the\ + \ caller." + properties: + available: + description: "Whether the caller can currently open this target." + nullable: true + type: "boolean" + folder: + description: "Optional personal grouping." + nullable: true + type: "string" + href: + description: "Navigation link, absent for unavailable targets." + nullable: true + type: "string" + id: + description: "Favorite identifier." + nullable: true + type: "integer" + label: + description: "Current display label; Unavailable when access is lost." + nullable: true + type: "string" + recordId: + description: "Canonical record key; null for non-record targets." + nullable: true + type: "string" + sortOrder: + description: "Position in the personal list." + nullable: true + type: "integer" + targetName: + description: "Metadata name of the table, app, process, report or saved\ + \ view." + nullable: true + type: "string" + targetType: + description: "Kind of navigation target." + enum: + - "TABLE" + - "APP" + - "RECORD" + - "PROCESS" + - "REPORT" + - "SAVED_VIEW" + nullable: true + type: "string" + type: "object" + CollaborationFollowRequestV1: + description: "Configure the caller's follow preferences." + properties: + notifyOnChange: + description: "Optional change notifications preference; omission retains\ + \ current setting." + nullable: true + type: "boolean" + notifyOnComment: + description: "Optional comment notifications preference; omission retains\ + \ current setting." + nullable: true + type: "boolean" + type: "object" + CollaborationFollowStateV1: + description: "The current caller's subscription state." + properties: + follow: + $ref: "#/components/schemas/CollaborationFollowV1" + description: "Subscription settings; null when not following." + nullable: true + following: + description: "Whether the caller follows the record." + nullable: true + type: "boolean" + type: "object" + CollaborationFollowV1: + description: "A subscription to record changes and comments." + properties: + id: + description: "Subscription identifier." + nullable: true + type: "integer" + mutedUntil: + description: "Notifications muted until this time, if set." + format: "date-time" + nullable: true + type: "string" + notifyOnChange: + description: "Receive record change digests." + nullable: true + type: "boolean" + notifyOnComment: + description: "Receive new comment notifications." + nullable: true + type: "boolean" + reason: + description: "How following began." + enum: + - "MANUAL" + - "AUTHOR" + - "COMMENTED" + - "MENTIONED" + - "ASSIGNED" + - "SHARED" + nullable: true + type: "string" + userId: + description: "Follower identity." + nullable: true + type: "string" + type: "object" + CollaborationFollowerListV1: + description: "Subscriptions for this record." + properties: + items: + description: "Subscriptions for this record." + items: + $ref: "#/components/schemas/CollaborationFollowV1" + nullable: true + type: "array" + nextCursor: + description: "Opaque continuation token; null when finished or the service\ + \ has no continuation support." + nullable: true + type: "string" + type: "object" + CollaborationMentionCandidateListV1: + description: "Candidates ranked with record participants first." + properties: + items: + description: "Candidates ranked with record participants first." + items: + $ref: "#/components/schemas/CollaborationMentionCandidateV1" + nullable: true + type: "array" + nextCursor: + description: "Opaque continuation token; null when finished or the service\ + \ has no continuation support." + nullable: true + type: "string" + type: "object" + CollaborationMentionCandidateV1: + description: "A matching person and their known parent access." + properties: + canRead: + description: "Recipient access: string true, false, or unknown when it cannot\ + \ be established." + enum: + - "true" + - "false" + - "unknown" + nullable: true + type: "string" + person: + $ref: "#/components/schemas/CollaborationPersonV1" + description: "Directory identity." + nullable: true + type: "object" + CollaborationMentionV1: + description: "A mention embedded in a comment." + properties: + userId: + description: "Mentioned user identity." + nullable: true + type: "string" + visible: + description: "Whether recipient access was confirmed when mentioned." + nullable: true + type: "boolean" + type: "object" + CollaborationMutationResultV1: + description: "Acknowledgement of a completed mutation." + properties: + success: + description: "True after the requested operation completes." + nullable: true + type: "boolean" + type: "object" + CollaborationPersonListV1: + description: "Matching active directory entries." + properties: + items: + description: "Matching active directory entries." + items: + $ref: "#/components/schemas/CollaborationPersonV1" + nullable: true + type: "array" + nextCursor: + description: "Opaque continuation token; null when finished or the service\ + \ has no continuation support." + nullable: true + type: "string" + type: "object" + CollaborationPersonV1: + description: "An active directory entry." + properties: + displayName: + description: "Display name." + nullable: true + type: "string" + email: + description: "Directory email, if known." + nullable: true + type: "string" + lastSeenAt: + description: "Most recent directory refresh time." + format: "date-time" + nullable: true + type: "string" + userId: + description: "Stable user identity." + nullable: true + type: "string" + type: "object" + CollaborationRecentListV1: + description: "Most recent visible targets, at most fifty." + properties: + items: + description: "Most recent visible targets, at most fifty." + items: + $ref: "#/components/schemas/CollaborationFavoriteV1" + nullable: true + type: "array" + nextCursor: + description: "Opaque continuation token; null when finished or the service\ + \ has no continuation support." + nullable: true + type: "string" + type: "object" + CollaborationTagApplyRequestV1: + description: "Add and remove tag associations in one request." + properties: + add: + description: "Tag identifiers to add; omitted means none." + items: + type: "integer" + nullable: true + type: "array" + remove: + description: "Tag identifiers to remove; omitted means none." + items: + type: "integer" + nullable: true + type: "array" + type: "object" + CollaborationTagCreateRequestV1: + description: "Create a tag or suggest one under the table policy." + properties: + color: + description: "Optional display color." + nullable: true + type: "string" + label: + description: "Required display label, 1 to 60 characters." + nullable: true + type: "string" + table: + description: "Required table metadata name." + nullable: true + type: "string" + type: "object" + CollaborationTagListV1: + description: "Available or applied tags." + properties: + items: + description: "Available or applied tags." + items: + $ref: "#/components/schemas/CollaborationTagV1" + nullable: true + type: "array" + nextCursor: + description: "Opaque continuation token; null when finished or the service\ + \ has no continuation support." + nullable: true + type: "string" + type: "object" + CollaborationTagV1: + description: "An approved tag or the caller's pending suggestion." + properties: + approved: + description: "Whether anyone may apply this tag." + nullable: true + type: "boolean" + color: + description: "Optional display color." + nullable: true + type: "string" + id: + description: "Tag identifier." + nullable: true + type: "integer" + label: + description: "Display label." + nullable: true + type: "string" + name: + description: "Normalized vocabulary name." + nullable: true + type: "string" + tableName: + description: "Table scope; null means global." + nullable: true + type: "string" + usageCount: + description: "Number of record associations." + nullable: true + type: "integer" + type: "object" + CollaborationTargetRequestV1: + description: "Target to star, unstar, or record as recently viewed." + properties: + recordId: + description: "Required for RECORD; omitted otherwise." + nullable: true + type: "string" + targetName: + description: "Required metadata name." + nullable: true + type: "string" + targetType: + description: "Required target kind." + enum: + - "TABLE" + - "APP" + - "RECORD" + - "PROCESS" + - "REPORT" + - "SAVED_VIEW" + nullable: true + type: "string" + type: "object" + CollaborationTimelineDetailV1: + description: "A permitted field change from a core audit." + properties: + fieldName: + description: "Changed field metadata name." + nullable: true + type: "string" + message: + description: "Optional audit message." + nullable: true + type: "string" + newValue: + description: "New display value." + nullable: true + type: "string" + oldValue: + description: "Previous display value." + nullable: true + type: "string" + type: "object" + CollaborationTimelineItemV1: + description: "One visible event in the merged record timeline." + properties: + actorName: + description: "Actor display name." + nullable: true + type: "string" + actorUserId: + description: "Actor identity, when known." + nullable: true + type: "string" + at: + description: "Event time." + format: "date-time" + nullable: true + type: "string" + details: + description: "Field details filtered by caller permissions." + items: + $ref: "#/components/schemas/CollaborationTimelineDetailV1" + nullable: true + type: "array" + id: + description: "Identifier within the source." + nullable: true + type: "integer" + kind: + description: "Event source." + enum: + - "ACTIVITY" + - "AUDIT" + nullable: true + type: "string" + refId: + description: "Related record key." + nullable: true + type: "string" + refTable: + description: "Related table metadata name." + nullable: true + type: "string" + summary: + description: "Human-readable event summary." + nullable: true + type: "string" + type: "object" + CollaborationTimelinePageV1: + description: "Merged events, newest first." + properties: + items: + description: "Merged events, newest first." + items: + $ref: "#/components/schemas/CollaborationTimelineItemV1" + nullable: true + type: "array" + nextCursor: + description: "Opaque continuation token; null when finished or the service\ + \ has no continuation support." + nullable: true + type: "string" + type: "object" + securitySchemes: + sessionUuidCookie: + in: "cookie" + name: "sessionUUID" + type: "apiKey" +info: + contact: + email: "contact@kingsrook.com" + description: "Record collaboration: favorites, recents, attachments, tags, conversations,\ + \ following, timeline and people" + title: "QQQ Middleware API extension: collaboration" + version: "1.0.0" +openapi: "3.0.3" +paths: + /qqq/v1/qbit/collaboration/favorites: + delete: + description: "Unstar a target. Uses the signed-in caller's permissions and record\ + \ visibility." + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTargetRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMutationResultV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Unstar a target" + get: + description: "List personal favorites. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationFavoriteListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "List personal favorites" + put: + description: "Star a target. Uses the signed-in caller's permissions and record\ + \ visibility." + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTargetRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMutationResultV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Star a target" + /qqq/v1/qbit/collaboration/recents: + get: + description: "List recent targets. Uses the signed-in caller's permissions and\ + \ record visibility." + parameters: + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationRecentListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "List recent targets" + post: + description: "Record a recent view. Uses the signed-in caller's permissions\ + \ and record visibility." + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTargetRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMutationResultV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Record a recent view" + /qqq/v1/qbit/collaboration/favorites/order: + patch: + description: "Reorder personal favorites. Uses the signed-in caller's permissions\ + \ and record visibility." + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationFavoriteOrderRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMutationResultV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Reorder personal favorites" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments: + get: + description: "List record attachments. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationAttachmentListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "List record attachments" + post: + description: "Upload an attachment. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/CollaborationAttachmentUploadRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationAttachmentV1" + description: "Uploaded attachment" + security: + - sessionUuidCookie: + - "N/A" + summary: "Upload an attachment" + /qqq/v1/qbit/collaboration/attachments/{id}/download: + get: + description: "Get a temporary download URL; clients follow url. Uses the signed-in\ + \ caller's permissions and record visibility." + parameters: + - description: "Collaboration object identifier." + in: "path" + name: "id" + required: true + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationDownloadV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Get a temporary download URL; clients follow url" + /qqq/v1/qbit/collaboration/attachments/{id}/preview: + get: + description: "Get a temporary preview URL; clients follow url. Uses the signed-in\ + \ caller's permissions and record visibility." + parameters: + - description: "Collaboration object identifier." + in: "path" + name: "id" + required: true + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationDownloadV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Get a temporary preview URL; clients follow url" + /qqq/v1/qbit/collaboration/attachments/{id}: + delete: + description: "Delete an attachment. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Collaboration object identifier." + in: "path" + name: "id" + required: true + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMutationResultV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Delete an attachment" + patch: + description: "Rename or describe an attachment. Uses the signed-in caller's\ + \ permissions and record visibility." + parameters: + - description: "Collaboration object identifier." + in: "path" + name: "id" + required: true + schema: + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationAttachmentPatchRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationAttachmentV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Rename or describe an attachment" + /qqq/v1/qbit/collaboration/tags: + get: + description: "Search table tag vocabulary. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "query" + name: "table" + required: true + schema: + type: "string" + - description: "Text to search in display names or vocabulary." + in: "query" + name: "q" + required: false + schema: + type: "string" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTagListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Search table tag vocabulary" + post: + description: "Create or suggest a tag. Uses the signed-in caller's permissions\ + \ and record visibility." + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTagCreateRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTagV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Create or suggest a tag" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/tags: + get: + description: "List applied record tags. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTagListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "List applied record tags" + put: + description: "Add and remove record tags. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTagApplyRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTagListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Add and remove record tags" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/comments: + get: + description: "Page record comment threads. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationCommentPageV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Page record comment threads" + post: + description: "Post a comment or reply. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationCommentPostRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationCommentV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Post a comment or reply" + /qqq/v1/qbit/collaboration/comments/{id}: + delete: + description: "Delete a comment thread. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Collaboration object identifier." + in: "path" + name: "id" + required: true + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMutationResultV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Delete a comment thread" + patch: + description: "Edit the caller's comment. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Collaboration object identifier." + in: "path" + name: "id" + required: true + schema: + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationCommentEditRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationCommentV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Edit the caller's comment" + /qqq/v1/qbit/collaboration/comments/{id}/resolve: + post: + description: "Resolve a comment thread. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Collaboration object identifier." + in: "path" + name: "id" + required: true + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMutationResultV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Resolve a comment thread" + /qqq/v1/qbit/collaboration/comments/{id}/reopen: + post: + description: "Reopen a comment thread. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Collaboration object identifier." + in: "path" + name: "id" + required: true + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMutationResultV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Reopen a comment thread" + /qqq/v1/qbit/collaboration/comments/{id}/history: + get: + description: "Read comment edit history. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Collaboration object identifier." + in: "path" + name: "id" + required: true + schema: + type: "string" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationCommentHistoryV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Read comment edit history" + /qqq/v1/qbit/collaboration/mentions/candidates: + get: + description: "Find people to mention on a record. Uses the signed-in caller's\ + \ permissions and record visibility." + parameters: + - description: "Parent table metadata name." + in: "query" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "query" + name: "pk" + required: true + schema: + type: "string" + - description: "Text to search in display names or vocabulary." + in: "query" + name: "q" + required: false + schema: + type: "string" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMentionCandidateListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Find people to mention on a record" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/follow: + delete: + description: "Unfollow a record. Uses the signed-in caller's permissions and\ + \ record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationFollowStateV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Unfollow a record" + get: + description: "Get the caller's follow state. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationFollowStateV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Get the caller's follow state" + put: + description: "Follow a record and configure notifications. Uses the signed-in\ + \ caller's permissions and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationFollowRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationFollowStateV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Follow a record and configure notifications" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/followers: + get: + description: "List record followers. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationFollowerListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "List record followers" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/timeline: + get: + description: "Page the merged record timeline. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + - description: "Timeline continuation token; alias for cursor. Supply only one." + in: "query" + name: "before" + required: false + schema: + type: "string" + - description: "Comma-separated AUDIT, ACTIVITY or activity type names (for\ + \ example COMMENTED)." + in: "query" + name: "types" + required: false + schema: + type: "string" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTimelinePageV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Page the merged record timeline" + /qqq/v1/qbit/collaboration/people: + get: + description: "Search the active people directory. Uses the signed-in caller's\ + \ permissions and record visibility." + parameters: + - description: "Text to search in display names or vocabulary." + in: "query" + name: "q" + required: false + schema: + type: "string" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationPersonListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Search the active people directory" + /qqq/v1/qbit/collaboration/people/{userId}: + get: + description: "Get an active directory person. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Stable directory user identity." + in: "path" + name: "userId" + required: true + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationPersonV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Get an active directory person" diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java new file mode 100644 index 0000000..3edb257 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java @@ -0,0 +1,81 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration; + +import java.nio.file.Files; +import java.nio.file.Path; +import com.kingsrook.qbits.collaboration.api.CollaborationApi; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData; +import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1; +import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** The committed extension fragment must describe exactly the shipped specs. + *******************************************************************************/ +class CollaborationApiContractTest +{ + /******************************************************************************* + ** Opt-in regeneration uses the same deterministic generator as QQQ core. + *******************************************************************************/ + @Test + void contract() throws Exception + { + var extension = CollaborationApi.extension(); + var version = new MiddlewareVersionV1(); + String generated = UiApiContract.generateExtensionYaml(version, extension); + Path path = Path.of("src/main/resources/openapi/qqq-v1/collaboration.yaml"); + if(Boolean.getBoolean("collaboration.openapi.update")) + { + Files.createDirectories(path.getParent()); + Files.writeString(path, generated); + } + assertThat(UiApiContract.check(generated, Files.readString(path), null, 1, + "mvnw.cmd -o -q -Dtest=CollaborationApiContractTest -Dcollaboration.openapi.update=true test")).isEmpty(); + var model = version.generateExtensionOpenAPIModel("qqq", extension); + assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration")); + assertThat(model.getPaths().keySet()).allMatch(pathName -> pathName.startsWith("/qqq/v1/qbit/collaboration/")); + assertThat(generated).contains("multipart/form-data", "format: \"binary\"", "clients follow url", "nextCursor"); + assertThat(extension.getSpecs()).hasSize(31); + } + + /******************************************************************************* + ** Hosts may opt out; default registration uses the default namespace. + *******************************************************************************/ + @Test + void registrationCanBeDisabled() throws Exception + { + QInstance disabled = new QInstance(); + disabled.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory").withUiApi(false)).produce(disabled); + assertThat(MiddlewareSpecExtensionsMetaData.ofOrWithNew(disabled).getExtensions()).isEmpty(); + QInstance enabled = new QInstance(); + enabled.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory")).produce(enabled); + assertThat(MiddlewareSpecExtensionsMetaData.ofOrWithNew(enabled).getExtensions()).hasSize(1); + assertThat(CollaborationApi.extension().getEffectivePathPrefix()).isEqualTo("/qbit/collaboration"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java new file mode 100644 index 0000000..bf37263 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java @@ -0,0 +1,430 @@ +/* + * 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.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.URL; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage; +import com.kingsrook.qbits.objectstorage.api.PresignGet; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface; +import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer; +import com.kingsrook.qqq.middleware.javalin.QJavalinImplementation; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** Real HTTP, real memory actions, and real parent locks; only file storage is fake. + *******************************************************************************/ +class CollaborationApiHttpTest extends PersonalServiceFixture +{ + private static final ObjectMapper JSON = new ObjectMapper(); + private final HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(); + private QApplicationJavalinServer server; + private String baseUrl; + private String sessionSuffix; + + /******************************************************************************* + ** Select a free loopback port and use the same mock-auth customization as hosts. + *******************************************************************************/ + @BeforeEach + void start() throws Exception + { + MemoryStorage.objects.clear(); + MemoryStorage.lastGet = null; + MemoryStorage.fail = false; + config.withAttachmentStorage(new QCodeReference(MemoryStorage.class)); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK) + .withCustomizer(new QCodeReference(TestSessions.class))); + int port; + try(ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + sessionSuffix = "-" + UUID.randomUUID(); + baseUrl = "http://127.0.0.1:" + port + "/qqq/v1/qbit/collaboration"; + server = new QApplicationJavalinServer(new AbstractQQQApplication() + { + @Override + public QInstance defineQInstance() + { + return (instance); + } + }).withPort(port).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false) + .withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true); + server.start(); + } + + /******************************************************************************* + ** Release HTTP and global server resources between tests. + *******************************************************************************/ + @AfterEach + void stop() + { + if(server != null) + { + server.stop(); + } + client.close(); + QJavalinImplementation.setSessionCookieHttpOnly(false); + MemoryStorage.objects.clear(); + } + + /******************************************************************************* + ** Every personal operation and offset cursor runs through the HTTP boundary. + *******************************************************************************/ + @Test + void favoritesAndRecents() throws Exception + { + String first = "{\"targetType\":\"RECORD\",\"targetName\":\"document\",\"recordId\":\"1\"}"; + String second = first.replace("\"1\"", "\"2\""); + assertThat(ok("PUT", "/favorites", first).get("success").asBoolean()).isTrue(); + ok("PUT", "/favorites", second); + JsonNode page = ok("GET", "/favorites?limit=1", null); + assertThat(page.get("items")).hasSize(1); + assertThat(page.at("/items/0/label").asText()).isEqualTo("First document"); + assertThat(page.at("/items/0/available").asBoolean()).isTrue(); + JsonNode next = ok("GET", "/favorites?limit=1&cursor=" + page.get("nextCursor").asText(), null); + int secondId = next.at("/items/0/id").asInt(); + assertThat(next.at("/items/0/recordId").asText()).isEqualTo("2"); + assertThat(next.get("nextCursor").isNull()).isTrue(); + ok("PATCH", "/favorites/order", "{\"ids\":[" + secondId + "],\"folders\":{\"" + secondId + "\":\"Work\"}}"); + assertThat(ok("GET", "/favorites", null).at("/items/0/folder").asText()).isEqualTo("Work"); + ok("DELETE", "/favorites", first); + assertThat(ok("GET", "/favorites", null).get("items")).hasSize(1); + ok("POST", "/recents", first); + assertThat(ok("GET", "/recents", null).at("/items/0/recordId").asText()).isEqualTo("1"); + assertThat(request("GET", "/favorites", null, "reader").body()).contains("\"items\":[]"); + assertError(400, "GET", "/favorites?cursor=broken", null, "alice"); + assertError(400, "PATCH", "/favorites/order", "{\"ids\":[99999]}", "alice"); + assertError(400, "PUT", "/favorites", "{\"targetType\":\"WRONG\",\"targetName\":\"document\"}", "alice"); + assertError(404, "PUT", "/favorites", first.replace("\"1\"", "\"3\""), "alice"); + } + + /******************************************************************************* + ** Multipart bytes, metadata, temporary URLs, rename, deletion and failure safety. + *******************************************************************************/ + @Test + void attachments() throws Exception + { + JsonNode uploaded = json(upload("alice", "report.txt", "hello", "/records/document/1/attachments"), 200); + int id = uploaded.get("id").asInt(); + assertThat(uploaded.get("fileName").asText()).isEqualTo("report.txt"); + assertThat(uploaded.get("sizeBytes").asLong()).isEqualTo(5); + assertThat(uploaded.get("description").asText()).isEqualTo("A report"); + assertThat(uploaded.get("createdByUserId").asText()).isEqualTo("alice"); + assertThat(uploaded.has("storageKey")).isFalse(); + assertThat(MemoryStorage.objects.values()).singleElement().satisfies(bytes -> assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo("hello")); + assertThat(ok("GET", "/records/document/1/attachments", null).get("items")).hasSize(1); + assertThat(ok("GET", "/attachments/" + id + "/download", null).get("url").asText()).startsWith("https://files.example.invalid/"); + assertThat(MemoryStorage.lastGet.getDownloadAs()).isEqualTo("report.txt"); + assertThat(MemoryStorage.lastGet.getTtl()).isEqualTo(Duration.ofMinutes(5)); + ok("GET", "/attachments/" + id + "/preview", null); + assertThat(MemoryStorage.lastGet.getDownloadAs()).isNull(); + assertThat(ok("PATCH", "/attachments/" + id, "{\"fileName\":\"renamed.txt\",\"description\":\"\"}").get("fileName").asText()).isEqualTo("renamed.txt"); + assertError(403, "PATCH", "/attachments/" + id, "{\"description\":\"no\"}", "reader"); + assertError(404, "GET", "/attachments/" + id + "/download", null, "outsider"); + assertError(400, "POST", "/records/document/1/attachments", "{}", "alice"); + assertThat(upload("reader", "x.txt", "x", "/records/document/1/attachments").statusCode()).isEqualTo(403); + assertThat(upload("alice", "x.txt", "x", "/records/document/3/attachments").statusCode()).isEqualTo(404); + config.withDefaultMaxBytes(2L); + assertThat(upload("alice", "x.txt", "toolarge", "/records/document/1/attachments").statusCode()).isEqualTo(400); + assertThat(MemoryStorage.objects).hasSize(1); + config.withDefaultMaxBytes(1000L).withBlockedExtensions(List.of("exe")); + assertThat(upload("alice", "x.exe", "x", "/records/document/1/attachments").statusCode()).isEqualTo(400); + MemoryStorage.fail = true; + assertThat(request("GET", "/attachments/" + id + "/download", null, "alice").statusCode()).isEqualTo(500); + MemoryStorage.fail = false; + ok("DELETE", "/attachments/" + id, null); + assertThat(MemoryStorage.objects).isEmpty(); + assertError(404, "GET", "/attachments/" + id + "/preview", null, "alice"); + } + + /******************************************************************************* + ** Vocabulary creation and record associations preserve caller permissions. + *******************************************************************************/ + @Test + void tags() throws Exception + { + JsonNode tag = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Urgent\",\"color\":\"red\"}"); + assertThat(tag.get("approved").asBoolean()).isTrue(); + assertThat(ok("GET", "/tags?table=document&q=urg", null).at("/items/0/name").asText()).isEqualTo("urgent"); + int id = tag.get("id").asInt(); + assertThat(ok("PUT", "/records/document/1/tags", "{\"add\":[" + id + "]}").get("items")).hasSize(1); + assertThat(ok("GET", "/records/document/1/tags", null).at("/items/0/id").asInt()).isEqualTo(id); + ok("PUT", "/records/document/1/tags", "{\"remove\":[" + id + "]}"); + assertThat(ok("GET", "/records/document/1/tags", null).get("items")).isEmpty(); + assertError(403, "POST", "/tags", "{\"table\":\"document\",\"label\":\"Denied\"}", "reader"); + assertError(400, "POST", "/tags", "{\"table\":\"document\",\"label\":\"\"}", "alice"); + assertError(400, "PUT", "/records/document/1/tags", "{\"add\":[null]}", "alice"); + assertError(400, "PUT", "/records/document/1/tags", "{\"add\":[1.5]}", "alice"); + } + + /******************************************************************************* + ** Comments, revisions, mentions, follow settings and the merged timeline. + *******************************************************************************/ + @Test + void conversationsAndTimeline() throws Exception + { + ok("DELETE", "/records/document/1/follow", null); + assertThat(ok("GET", "/records/document/1/follow", null).get("following").asBoolean()).isFalse(); + JsonNode root = ok("POST", "/records/document/1/comments", "{\"body\":\"First\"}"); + int id = root.get("id").asInt(); + assertThat(root.get("authorUserId").asText()).isEqualTo("alice"); + ok("POST", "/records/document/1/comments", "{\"body\":\"Second\"}"); + JsonNode page = ok("GET", "/records/document/1/comments?limit=1", null); + assertThat(page.get("threads")).hasSize(1); + String cursor = page.get("nextCursor").asText(); + JsonNode next = ok("GET", "/records/document/1/comments?limit=1&cursor=" + cursor, null); + assertThat(next.at("/threads/0/id").asInt()).isEqualTo(id); + assertThat(next.get("nextCursor").isNull()).isTrue(); + ok("POST", "/records/document/1/comments", "{\"body\":\"Reply\",\"parentCommentId\":" + id + "}"); + assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/replies")).hasSize(1); + ok("PATCH", "/comments/" + id, "{\"body\":\"Edited @[Alice](user:alice)\"}"); + assertThat(ok("GET", "/comments/" + id + "/history", null).at("/items/0/body").asText()).isEqualTo("First"); + ok("POST", "/comments/" + id + "/resolve", null); + assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isTrue(); + ok("POST", "/comments/" + id + "/reopen", null); + assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isFalse(); + JsonNode people = ok("GET", "/people?q=alice", null); + assertThat(people.at("/items/0/userId").asText()).isEqualTo("alice"); + assertThat(ok("GET", "/people/alice", null).get("displayName").asText()).isEqualTo("alice"); + JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=alice", null).at("/items/0"); + assertThat(candidate.at("/person/userId").asText()).isEqualTo("alice"); + assertThat(candidate.get("canRead").asText()).isEqualTo("unknown"); + assertThat(ok("GET", "/records/document/1/follow", null).at("/follow/reason").asText()).isEqualTo("COMMENTED"); + JsonNode state = ok("PUT", "/records/document/1/follow", "{\"notifyOnChange\":false,\"notifyOnComment\":true}"); + assertThat(state.at("/follow/notifyOnChange").asBoolean()).isFalse(); + assertThat(ok("GET", "/records/document/1/followers", null).at("/items/0/userId").asText()).isEqualTo("alice"); + JsonNode timeline = ok("GET", "/records/document/1/timeline?limit=1&types=ACTIVITY", null); + assertThat(timeline.get("items")).hasSize(1); + assertThat(timeline.at("/items/0/kind").asText()).isEqualTo("ACTIVITY"); + String before = timeline.get("nextCursor").asText(); + JsonNode older = ok("GET", "/records/document/1/timeline?before=" + before + "&types=ACTIVITY", null); + assertThat(older.at("/items/0/id").asLong()).isNotEqualTo(timeline.at("/items/0/id").asLong()); + ok("DELETE", "/comments/" + id, null); + assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/deleted").asBoolean()).isTrue(); + ok("DELETE", "/records/document/1/follow", null); + assertThat(ok("GET", "/records/document/1/follow", null).get("follow").isNull()).isTrue(); + assertError(404, "GET", "/people/missing", null, "alice"); + assertError(403, "PATCH", "/comments/" + id, "{\"body\":\"no\"}", "reader"); + assertError(400, "GET", "/records/document/1/timeline?cursor=" + cursor, null, "alice"); + } + + /******************************************************************************* + ** Missing and locked parents are indistinguishable, across all record panels. + *******************************************************************************/ + @Test + void authorizationAndValidation() throws Exception + { + for(String feature : List.of("attachments", "tags", "comments", "follow", "followers", "timeline")) + { + HttpResponse hidden = request("GET", "/records/document/3/" + feature, null, "alice"); + HttpResponse absent = request("GET", "/records/document/999/" + feature, null, "alice"); + assertThat(hidden.statusCode()).isEqualTo(404); + assertThat(hidden.body()).isEqualTo(absent.body()); + assertThat(json(hidden, 404).get("error").asText()).isEqualTo("Not found"); + } + assertError(404, "POST", "/records/document/3/comments", "not json", "alice"); + assertError(404, "GET", "/mentions/candidates?table=document&pk=3", null, "alice"); + assertError(403, "POST", "/records/document/1/comments", "{\"body\":\"Denied\"}", "reader"); + assertError(400, "POST", "/records/document/1/comments", "{\"body\":\"\"}", "alice"); + for(String body : List.of("not json", "[]", "null", "{\"body\":42}\", \"{}")) + { + assertError(400, "POST", "/records/document/1/comments", body, "alice"); + } + for(String limit : List.of("0", "101", "bad")) + { + assertError(400, "GET", "/records/document/1/comments?limit=" + limit, null, "alice"); + } + ok("GET", "/records/document/1/comments?limit=100", null); + ok("GET", "/records/document/1/timeline?limit=100", null); + ok("GET", "/people?limit=100", null); + assertError(400, "GET", "/records/document/1/comments?cursor=invalid", null, "alice"); + assertError(400, "GET", "/records/document/1/timeline?before=a&cursor=b", null, "alice"); + assertError(400, "GET", "/tags", null, "alice"); + assertError(400, "GET", "/comments/invalid/history", null, "alice"); + assertError(404, "GET", "/comments/999/history", null, "alice"); + } + + /******************************************************************************* + ** Send JSON with a persistent caller cookie; credentials travel over HTTP. + *******************************************************************************/ + private HttpResponse request(String method, String path, String body, String caller) throws Exception + { + return (client.send(HttpRequest.newBuilder(URI.create(baseUrl + path)).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=" + caller + sessionSuffix).header("Content-Type", "application/json") + .method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString())); + } + + /******************************************************************************* + ** Assert transport status before interpreting the returned wire body. + *******************************************************************************/ + private JsonNode json(HttpResponse response, int status) throws Exception + { + assertThat(response.statusCode()).as(response.body()).isEqualTo(status); + return (JSON.readTree(response.body())); + } + + /******************************************************************************* + ** Successful request by the record author. + *******************************************************************************/ + private JsonNode ok(String method, String path, String body) throws Exception + { + return (json(request(method, path, body, "alice"), 200)); + } + + /******************************************************************************* + ** The live server publishes the same typed extension that its routes execute. + *******************************************************************************/ + @Test + void publishedDescription() throws Exception + { + String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qqq/")); + JsonNode description = json(client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/qqq/v1/openapi.json")).GET().build(), + HttpResponse.BodyHandlers.ofString()), 200); + JsonNode schemas = description.at("/components/schemas"); + assertThat(schemas.has("CollaborationCommentPageV1")).isTrue(); + assertThat(schemas.at("/CollaborationFollowV1/properties/reason/enum")).hasSize(6); + assertThat(description.get("paths").has("/qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments")).isTrue(); + assertThat(description.get("paths").has("/qqq/v1/qbit/collaboration/my-work")).isFalse(); + JsonNode version = json(client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/qqq/v1/version")).GET().build(), + HttpResponse.BodyHandlers.ofString()), 200); + assertThat(version.at("/extensions/0/name").asText()).isEqualTo("collaboration"); + assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.0.0"); + } + + /******************************************************************************* + ** Framework errors always contain a human-readable error property. + *******************************************************************************/ + private void assertError(int status, String method, String path, String body, String caller) throws Exception + { + assertThat(json(request(method, path, body, caller), status).get("error").asText()).isNotBlank(); + } + + /******************************************************************************* + ** Build an actual multipart body without filesystem fixtures. + *******************************************************************************/ + private HttpResponse upload(String caller, String name, String content, String path) throws Exception + { + String boundary = "CollaborationBoundary"; + String payload = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"description\"\r\n\r\nA report\r\n" + + "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"" + name + "\"\r\nContent-Type: text/plain\r\n\r\n" + + content + "\r\n--" + boundary + "--\r\n"; + return (client.send(HttpRequest.newBuilder(URI.create(baseUrl + path)).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=" + caller + sessionSuffix).header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofString(payload)).build(), HttpResponse.BodyHandlers.ofString())); + } + + /******************************************************************************* + ** Test auth maps stable cookies to distinct users, grants and tenant locks. + *******************************************************************************/ + public static class TestSessions implements QAuthenticationModuleCustomizerInterface + { + @Override + public void customizeSession(QInstance qInstance, QSession session, Map context) + { + String caller = session.getUuid().split("-")[0]; + session.withUser(new QUser().withIdReference(caller).withFullName(caller)); + session.withPermissions("document.read").withSecurityKeyValue("tenant", caller.equals("outsider") ? "b" : "a"); + if(caller.equals("alice")) + { + session.withPermissions("document.write"); + } + } + } + + /******************************************************************************* + ** Public no-arg adapter loaded through host configuration, with in-memory bytes. + *******************************************************************************/ + public static class MemoryStorage extends AttachmentStorage + { + static final Map objects = new ConcurrentHashMap<>(); + static volatile PresignGet lastGet; + static volatile boolean fail; + + @Override + public long maxBytes(String name) + { + return (1000000); + } + + @Override + public void put(String name, PutRequest request) + { + try + { + objects.put(request.getKey(), request.getStream().readAllBytes()); + } + catch(IOException e) + { + throw (new IllegalStateException(e)); + } + } + + @Override + public void delete(String name, String key) + { + objects.remove(key); + } + + @Override + public URL presign(String name, PresignGet request) + { + if(fail) + { + throw (new IllegalStateException("Test provider unavailable")); + } + lastGet = request; + try + { + return (URI.create("https://files.example.invalid/" + request.getKey()).toURL()); + } + catch(Exception e) + { + throw (new IllegalStateException(e)); + } + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java index 6764abf..61c4506 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java @@ -296,7 +296,7 @@ public class ConversationServicesTest extends PersonalServiceFixture comments.post("document", 1, "new reply", first.id(), null); assertThat(comments.list("document", 1, null, 1).threads().getFirst().id()).isEqualTo(first.id()); assertThatThrownBy(() -> comments.list("document", 1, "bad!", 20)).hasMessage("Invalid cursor"); - assertThatThrownBy(() -> comments.list("document", 1, null, 51)).hasMessageContaining("Limit"); + assertThatThrownBy(() -> comments.list("document", 1, null, 101)).hasMessageContaining("Limit"); assertThatThrownBy(() -> comments.post("document", 1, " ", null, null)).hasMessageContaining("body"); assertThatThrownBy(() -> comments.post("document", 1, "a".repeat(20001), null, null)).hasMessageContaining("body"); assertThatThrownBy(() -> comments.post("document", 1, "ok", null, "missing")).hasMessage("Unknown field"); @@ -363,7 +363,7 @@ public class ConversationServicesTest extends PersonalServiceFixture assertThat(comments.mentionCandidates("document", 1, "eve").getFirst().canRead()).contains(false); CollaborationRecords.update(CollaborationTables.USER, new QRecord().withValue("id", eve.getValue("id")).withValue("active", false)); assertThat(new PeopleService().search("eve", 50)).isEmpty(); - assertThatThrownBy(() -> new PeopleService().search("", 51)).hasMessageContaining("Limit"); + assertThatThrownBy(() -> new PeopleService().search("", 101)).hasMessageContaining("Limit"); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java index db5a153..3a0237e 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java @@ -134,7 +134,7 @@ public class TimelineServiceTest extends PersonalServiceFixture assertThat(timeline.timeline("document", 1, null, 50, null).items()).hasSize(3).allSatisfy(item -> assertThat(item.kind()).isEqualTo("ACTIVITY")); assertThatThrownBy(() -> timeline.timeline("document", 3, null, 50, null)).hasMessage("Not found"); assertThatThrownBy(() -> timeline.timeline("document", 1, "invalid", 50, null)).hasMessage("Invalid cursor"); - assertThatThrownBy(() -> timeline.timeline("document", 1, null, 51, null)).hasMessageContaining("Limit"); + assertThatThrownBy(() -> timeline.timeline("document", 1, null, 101, null)).hasMessageContaining("Limit"); instance.getTables().remove("audit"); assertThat(timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items()).isEmpty(); } -- 2.54.0 From 77d005efd31b5792993f7c7ecf7db060153f8aa1 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 20:32:03 -0500 Subject: [PATCH 02/12] 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 --- CHANGELOG.md | 7 +- README.md | 66 +- docs/DESIGN.md | 38 +- .../collaboration/CollaborationCleanup.java | 63 ++ .../CollaborationQBitConfig.java | 77 +- .../CollaborationQBitProducer.java | 3 +- .../collaboration/CollaborationRealtime.java | 104 ++ .../collaboration/CollaborationRecords.java | 56 +- .../collaboration/CollaborationSchema.java | 7 + .../CollaborationSummaryProvider.java | 101 ++ .../collaboration/CoreCollaboration.java | 105 ++ .../FeatureSettingsCustomizer.java | 98 ++ .../FeatureSettingsProvider.java | 75 ++ .../qbits/collaboration/WorkCleanup.java | 4 +- .../qbits/collaboration/WorkSupport.java | 8 +- .../collaboration/api/CollaborationApi.java | 11 + .../api/CollaborationJsonSpec.java | 31 +- .../api/CollaborationSchemas.java | 1 + .../collaboration/api/CollaborationViews.java | 33 + .../api/CollaborationWorkSchemas.java | 375 +++++++ .../api/CreateAccessRequestSpec.java | 86 ++ .../collaboration/api/CreateSharesSpec.java | 82 ++ .../collaboration/api/CreateTaskSpec.java | 82 ++ .../collaboration/api/GetMyWorkSpec.java | 72 ++ .../collaboration/api/GetRecordLinkSpec.java | 71 ++ .../api/ListMyAccessRequestsSpec.java | 72 ++ .../collaboration/api/ListMyTasksSpec.java | 71 ++ .../collaboration/api/ListTasksSpec.java | 76 ++ .../collaboration/api/PatchTaskSpec.java | 128 +++ .../collaboration/api/PreviewSharesSpec.java | 83 ++ .../collaboration/api/PutAssignmentSpec.java | 82 ++ .../collaboration/app/CollaborationHome.java | 2 +- .../attachments/AttachmentCleanup.java | 4 +- .../attachments/AttachmentRecords.java | 2 +- .../attachments/AttachmentSecurity.java | 18 +- .../attachments/AttachmentsService.java | 21 +- .../comments/CommentsService.java | 8 +- .../collaboration/follow/FollowService.java | 4 +- .../model/CollaborationTables.java | 8 +- .../collaboration/mywork/MyWorkService.java | 2 +- .../collaboration/notify/Notifications.java | 19 +- ... => CollaborationRecordAccessChecker.java} | 19 +- .../security/CollaborationRowSecurity.java | 16 +- .../security/ParentRecordAccess.java | 45 +- .../security/ParentTableRestriction.java | 58 -- .../security/UserSessionSupplier.java | 37 - .../sharing/AccessRequestService.java | 35 +- .../collaboration/sharing/SharingService.java | 6 +- .../tags/TagCriteriaExpander.java | 125 +++ .../tags/TagFilterTranslator.java | 126 --- .../tags/TagsMetaDataProvider.java | 121 +++ .../qbits/collaboration/tags/TagsService.java | 68 +- .../collaboration/tasks/TasksService.java | 6 +- .../openapi/qqq-v1/collaboration.yaml | 941 ++++++++++++++++++ .../CollaborationApiContractTest.java | 2 +- .../CollaborationApiHttpTest.java | 57 +- .../ConversationServicesTest.java | 18 +- .../collaboration/CoreContractsTest.java | 202 ++++ .../collaboration/PersonalServiceFixture.java | 3 +- .../qbits/collaboration/SharingWorkTest.java | 10 +- .../qbits/collaboration/TagsServiceTest.java | 54 +- .../attachments/AttachmentsServiceTest.java | 1 + 62 files changed, 3741 insertions(+), 465 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/CollaborationCleanup.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/CollaborationRealtime.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/CollaborationSummaryProvider.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsProvider.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationWorkSchemas.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CreateAccessRequestSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CreateSharesSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CreateTaskSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/GetMyWorkSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/GetRecordLinkSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListMyAccessRequestsSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListMyTasksSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/ListTasksSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/PatchTaskSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/PreviewSharesSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/PutAssignmentSpec.java rename src/main/java/com/kingsrook/qbits/collaboration/security/{OtherUserAccess.java => CollaborationRecordAccessChecker.java} (73%) delete mode 100644 src/main/java/com/kingsrook/qbits/collaboration/security/ParentTableRestriction.java delete mode 100644 src/main/java/com/kingsrook/qbits/collaboration/security/UserSessionSupplier.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/tags/TagCriteriaExpander.java delete mode 100644 src/main/java/com/kingsrook/qbits/collaboration/tags/TagFilterTranslator.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/tags/TagsMetaDataProvider.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/CoreContractsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c51fb4..4f041e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Added -- Collaboration UI API extension `1.0.0`: 31 typed operations for favorites, recents, attachments, tags, comments, mentions, follow, timeline and people under `/qqq/v1/qbit/collaboration`, enabled by default and disabled with `withUiApi(false)`. +- Collaboration UI API extension `1.0.0`: 42 typed operations for favorites, recents, attachments, tags, comments, mentions, follow, timeline, people, sharing, access requests, tasks, assignment and My work 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. @@ -14,6 +14,11 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### 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 and the optional followers SPI await missing core hooks. + - 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 diff --git a/README.md b/README.md index 2c34b04..fd6fa00 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # QBit Collaboration -Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and standalone filter translation. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g remain planned; general runtime overrides are still deferred. TAGS mode settings are read directly by the service. +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 remain planned. 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). @@ -10,7 +10,7 @@ Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/ - QQQ `4.1.0-SNAPSHOT` installed from develop, including UI API extension point #1128. The build will move to `4.1.0-RC.6` once released; middleware Javalin is a compile dependency. - 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-SNAPSHOT`, targeting RC.6. ## Install @@ -126,7 +126,7 @@ 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. @@ -142,7 +142,7 @@ Run services in the caller's `QContext`. Register host tables before producing t ```java config.withNotifier(new QCodeReference(MyCollaborationNotifier.class)) - .withUserSessionSupplier(new QCodeReference(MyUserSessions.class)) + .withUserSessionProvider(new QCodeReference(MyUserSessions.class)) .withFollowActionTables(List.of("order")) .withFollowChangeTables(List.of("order")) .withSchedulerName("scheduler"); @@ -169,7 +169,7 @@ TimelinePage timeline = new TimelineService().timeline("order", "123", null, 20, 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 `
.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:)`, deduplicate IDs and reject more than 50. `OtherUserAccess.canRead` returns `Optional` 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. +- Mentions use `@[Name](user:)`, deduplicate IDs and reject more than 50. `CollaborationRecordAccessChecker.canRead` returns `Optional` 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. @@ -193,7 +193,7 @@ UI API endpoints are described below. Runtime feature enablement, mirrored WRITE | `blockedExtensions` | Empty | Case-insensitive extension denylist, with or without leading dots. | | `recentsPerUser` | `200` | Positive retention count per user. Enforced by the trim process. | | `notifier` | `QCodeReference(NoOpNotifier.class)` | Implements `CollaborationNotifier`; replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released. | -| `userSessionSupplier` | None | `QCodeReference` implementing `UserSessionSupplier`; missing user sessions mean unknown access. | +| `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. | @@ -268,23 +268,12 @@ 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. Hosts keep using this trusted callback until the core -cleanup registration contract ships. The home shows session-visible vocabulary +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. -`TagFilterTranslator.recordIds(table, operatorName, tags)` supports `IN` (any), -`CONTAINS_ALL`, `NOT_IN` (none), `IS_BLANK`, and `IS_NOT_BLANK`. Every operator -returns matching visible parent IDs as strings, including complements. Exactly -10,000 results are allowed; larger results throw. Unknown tags match nothing; -empty IN matches nothing and empty CONTAINS_ALL/NOT_IN match all visible parents. -Numeric inputs are IDs, strings are normalized names; if a global and local tag -share a name, the global tag wins (use IDs to disambiguate). - -**Filter/quick-search integration lands after QRun-IO/qqq#1140 is released.** This -phase does not register a virtual filter field, core expander, `QSubQuery`, tag -column filler, or `#tag`/host-record quick-search provider. The standalone -translator deliberately imports none of those unreleased types. +`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 @@ -480,3 +469,40 @@ idempotent hook. Schema generation includes the four tables, supporting indexes `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 targets QQQ RC.6 using the locally installed `4.1.0-SNAPSHOT`. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata: + +```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_` 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. The optional followers provider is skipped because `RecordFollowersProviderInterface` is absent from the installed jar. Selectable Tags values remain unavailable because the overlay has no output hook. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ed3c434..b228f4e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,7 +2,7 @@ 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; phase e comments/follow/timeline is implemented against released RC.5; phase d tags and phases h/i sharing, tasks and ownership services are implemented. Phases f/g and endpoint publication remain separate work. 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 | |---|---|---|---| @@ -19,40 +19,35 @@ 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 and realtime publication are future integrations; attachment services now write activity. 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/access interfaces are explicit stand-ins for #1125 and the unreleased recipient/session contracts. -- **Security at both boundaries.** Services check the real parent before disclosing any row. Direct table writes are denied even to admins. `ParentTableRestriction` adds a READ-table condition outside caller OR clauses; `ParentRowCustomizer` checks record locks on fetched rows. Revision and mention rows mirror their comment's parent reference to use the same pre-query restriction. Core mirrored locks/page refill and runtime feature resolution remain deferred; counts and unpinned page lengths have the same limits as attachments. +- **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 remain deferred to QRun-IO/qqq#1128; no unreleased core dependency, runtime overlay, mirrored-lock implementation or realtime/UI delivery is introduced. +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 -- **RC.5 boundary.** `tags/TagFilterTranslator` uses operator names, QQQ queries and - string parent IDs. It returns matching rows for all operators, so NOT_IN and - IS_BLANK enumerate the visible host-table complement. The 10,000 limit applies - to final matching IDs, not assignment count. It pages parent reads under the - caller's permissions and locks. Filter/quick-search integration lands after - QRun-IO/qqq#1140 is released; no unreleased core types or virtual fields are used. +- **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 inherits `ParentTableRestriction` and the parent row customizer, + 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 @@ -81,7 +76,7 @@ The service API and configuration are documented in [README](../README.md#commen 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 metadata is present; host-record tag search awaits #1140. + label search and host-record tag search use the core virtual-field expander. ## Phases h/i implementation choices @@ -118,3 +113,16 @@ The service API and configuration are documented in [README](../README.md#commen 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. diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationCleanup.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationCleanup.java new file mode 100644 index 0000000..b7e8049 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationCleanup.java @@ -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 primaryKeys) throws QException + { + new AttachmentCleanup().onParentsDeleted(tableName, primaryKeys); + for(String table : List.of(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")); + } + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java index 228591e..c97d835 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java @@ -22,10 +22,14 @@ package com.kingsrook.qbits.collaboration; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import com.kingsrook.qbits.collaboration.notify.NoOpNotifier; import 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; @@ -38,8 +42,12 @@ public class CollaborationQBitConfig implements QBitConfig public static final String TABLE_NAME_PREFIX = "collaboration_"; public static final String PERMISSION_BASE_NAME = "collaboration"; + private List defaultFeatures = List.of(RecordFeature.FAVORITES, RecordFeature.RECENTS, RecordFeature.TIMELINE); + private Map> tableFeatures = new LinkedHashMap<>(); + private List mirroredSecurityKeyTypes = List.of(); + private QCodeReference notifier = new QCodeReference(NoOpNotifier.class); - private QCodeReference userSessionSupplier = null; + private QCodeReference userSessionProvider = null; private String defaultTagMode = "FREE"; private List tagActionTables = List.of(); private List followActionTables = List.of(); @@ -432,19 +440,19 @@ public class CollaborationQBitConfig implements QBitConfig } /******************************************************************************* - ** Get userSessionSupplier. + ** Get userSessionProvider. *******************************************************************************/ - public QCodeReference getUserSessionSupplier() + public QCodeReference getUserSessionProvider() { - return (userSessionSupplier); + return (userSessionProvider); } /******************************************************************************* - ** Configure userSessionSupplier. + ** Configure userSessionProvider. *******************************************************************************/ - public CollaborationQBitConfig withUserSessionSupplier(QCodeReference userSessionSupplier) + public CollaborationQBitConfig withUserSessionProvider(QCodeReference userSessionProvider) { - this.userSessionSupplier = userSessionSupplier; + this.userSessionProvider = userSessionProvider; return (this); } @@ -566,4 +574,59 @@ public class CollaborationQBitConfig implements QBitConfig 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 getMirroredSecurityKeyTypes() + { + return (mirroredSecurityKeyTypes); + } + + /******************************************************************************* + ** Configure mirrored parent locks. + *******************************************************************************/ + public CollaborationQBitConfig withMirroredSecurityKeyTypes(List types) + { + mirroredSecurityKeyTypes = List.copyOf(types); + return (this); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java index c5ff21d..9ea096c 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java @@ -134,7 +134,6 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer 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); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationRecords.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationRecords.java index 5bb3d9f..a396690 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationRecords.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationRecords.java @@ -115,6 +115,56 @@ public final class CollaborationRecords check(new DeleteAction().execute(input).getRecordsWithErrors()); } + /******************************************************************************* + ** Trusted after-delete reads must see children whose parent no longer exists. + *******************************************************************************/ + public static List 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 @@ -122,7 +172,11 @@ public final class CollaborationRecords *******************************************************************************/ private static QTableMetaData serviceMetadata(String table) { - QTableMetaData metadata = QContext.getQInstance().getTable(table).clone().withCustomizers(null); + 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); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java index 7d117f1..1215f18 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java @@ -60,6 +60,13 @@ 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.of() : table.getUniqueKeys()) { diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSummaryProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSummaryProvider.java new file mode 100644 index 0000000..38cae58 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSummaryProvider.java @@ -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; + +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.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())); + } + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java b/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java new file mode 100644 index 0000000..c543ee5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java @@ -0,0 +1,105 @@ +/* + * 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 REFERENCES = Map.ofEntries( + 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.withOverrideProvider(new QCodeReference(FeatureSettingsProvider.class)); + features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.tags.TagsMetaDataProvider.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)); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsCustomizer.java b/src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsCustomizer.java new file mode 100644 index 0000000..8f58f7d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsCustomizer.java @@ -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 postInsert(InsertInput input, List records) throws QException + { + refresh(input.getTransaction()); + return (records); + } + + /******************************************************************************* + ** Refresh after successful update. + *******************************************************************************/ + @Override + public List postUpdate(UpdateInput input, List records, Optional> oldRecords) throws QException + { + refresh(input.getTransaction()); + return (records); + } + + /******************************************************************************* + ** Refresh after successful delete. + *******************************************************************************/ + @Override + public List postDelete(DeleteInput input, List 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)); + } + }); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsProvider.java new file mode 100644 index 0000000..e31fe58 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsProvider.java @@ -0,0 +1,75 @@ +/* + * 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; + +/******************************************************************************* + ** 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 getOverrides(QInstance instance) throws QException + { + List 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>() {})); + } + result.add(override); + } + catch(Exception e) + { + throw (new QException("Invalid collaboration feature setting", e)); + } + } + return (result); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/WorkCleanup.java b/src/main/java/com/kingsrook/qbits/collaboration/WorkCleanup.java index 485d2c1..892ee02 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/WorkCleanup.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/WorkCleanup.java @@ -95,7 +95,7 @@ public class WorkCleanup implements RecordChangeListenerInterface { while(true) { - List rows = CollaborationRecords.query(child, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table) + List 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()) { @@ -103,7 +103,7 @@ public class WorkCleanup implements RecordChangeListenerInterface } for(QRecord row : rows) { - CollaborationRecords.delete(child, row.getValue("id")); + CollaborationRecords.cleanupDelete(child, row.getValue("id")); } } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/WorkSupport.java b/src/main/java/com/kingsrook/qbits/collaboration/WorkSupport.java index 03a4758..f83c6ed 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/WorkSupport.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/WorkSupport.java @@ -24,7 +24,7 @@ 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.OtherUserAccess; +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; @@ -77,9 +77,9 @@ public final class WorkSupport public static void requireReader(String user, String table, Serializable id) throws QException { if(user != null && (user.isBlank() || (!user.equals(PersonalRecords.currentUserId()) - && !new OtherUserAccess().canRead(user, table, id).orElse(false)))) + && !new CollaborationRecordAccessChecker().canRead(user, table, id).orElse(false)))) { - throw (new QException(new PeopleService().displayName(user) + " can't open this record.")); + throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException(new PeopleService().displayName(user) + " can't open this record.")); } } @@ -90,7 +90,7 @@ public final class WorkSupport { if((required && (value == null || value.isBlank())) || (value != null && value.length() > max)) { - throw (new QException("Text must contain " + (required ? "1" : "0") + " to " + max + " characters.")); + throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Text must contain " + (required ? "1" : "0") + " to " + max + " characters.")); } } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java index c124145..d1bf034 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java @@ -84,6 +84,17 @@ public final class CollaborationApi .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)); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationJsonSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationJsonSpec.java index 923e71f..4818401 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationJsonSpec.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationJsonSpec.java @@ -159,12 +159,14 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec 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 : Type.STRING))); + .withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") ? Type.BOOLEAN : Type.STRING))); } /******************************************************************************* @@ -364,4 +366,31 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec { 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 recipients(List 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); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java index 839454f..4e16d50 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java @@ -711,6 +711,7 @@ public final class CollaborationSchemas schemas.put("CollaborationCommentPostRequestV1", new CollaborationCommentPostRequestV1(null, null, null).toSchema()); schemas.put("CollaborationCommentEditRequestV1", new CollaborationCommentEditRequestV1(null).toSchema()); schemas.put("CollaborationFollowRequestV1", new CollaborationFollowRequestV1(null, null).toSchema()); + schemas.putAll(CollaborationWorkSchemas.components()); return (schemas); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java index f524528..7f90bd8 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java @@ -155,4 +155,37 @@ final class CollaborationViews { return (new CollaborationMentionCandidateV1(person(value.person()), value.canRead().map(Object::toString).orElse("unknown"))); } + + /******************************************************************************* + ** Project Share public fields. + *******************************************************************************/ + static CollaborationWorkSchemas.CollaborationShareV1 share(com.kingsrook.qqq.backend.core.model.data.QRecord row) + { + return (new CollaborationWorkSchemas.CollaborationShareV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("fromUserId"), row.getValueString("toUserId"), row.getValueString("message"), row.getValueBoolean("recipientCouldRead"), row.getValueInstant("sentAt"))); + } + + /******************************************************************************* + ** Project AccessRequest public fields. + *******************************************************************************/ + static CollaborationWorkSchemas.CollaborationAccessRequestV1 accessRequest(com.kingsrook.qqq.backend.core.model.data.QRecord row) + { + return (new CollaborationWorkSchemas.CollaborationAccessRequestV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("requesterUserId"), row.getValueString("message"), row.getValueString("status"), row.getValueInstant("handledAt"), row.getValueString("note"))); + } + + /******************************************************************************* + ** Project Task public fields. + *******************************************************************************/ + static CollaborationWorkSchemas.CollaborationTaskV1 task(com.kingsrook.qqq.backend.core.model.data.QRecord row) + { + return (new CollaborationWorkSchemas.CollaborationTaskV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("title"), row.getValueString("notes"), row.getValueString("assigneeUserId"), row.getValueInstant("dueAt"), row.getValueInstant("remindAt"), row.getValueString("status"), row.getValueString("createdByUserId"), row.getValueInstant("completedAt"), row.getValueString("completedByUserId"))); + } + + /******************************************************************************* + ** Project Assignment public fields. + *******************************************************************************/ + static CollaborationWorkSchemas.CollaborationAssignmentV1 assignment(com.kingsrook.qqq.backend.core.model.data.QRecord row) + { + return (new CollaborationWorkSchemas.CollaborationAssignmentV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("ownerUserId"), row.getValueString("assigneeUserId"))); + } + } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationWorkSchemas.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationWorkSchemas.java new file mode 100644 index 0000000..318d469 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationWorkSchemas.java @@ -0,0 +1,375 @@ +/* + * 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 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) 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)))))); + } + } + + /******************************************************************************* + ** Versioned ShareList body. + *******************************************************************************/ + public record CollaborationShareListV1(List 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 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) 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)))))); + } + } + + /******************************************************************************* + ** 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 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) 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)))))); + } + } + + /******************************************************************************* + ** 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 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) 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)))))); + } + } + + /******************************************************************************* + ** Versioned MyWork body. + *******************************************************************************/ + public record CollaborationMyWorkV1(Long openTaskCount, List tasks, Map 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 components() + { + Map 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).toSchema()); + schemas.put("CollaborationShareListV1", new CollaborationShareListV1(null).toSchema()); + schemas.put("CollaborationShareRequestV1", new CollaborationShareRequestV1(null, null).toSchema()); + schemas.put("CollaborationAccessRequestV1", new CollaborationAccessRequestV1(null, null, null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationAccessRequestCreateRequestV1", new CollaborationAccessRequestCreateRequestV1(null, null, null, null).toSchema()); + schemas.put("CollaborationAccessRequestListV1", new CollaborationAccessRequestListV1(null, null).toSchema()); + schemas.put("CollaborationTaskV1", new CollaborationTaskV1(null, null, null, null, null, null, null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationTaskCreateRequestV1", new CollaborationTaskCreateRequestV1(null, null, null, null, null).toSchema()); + schemas.put("CollaborationTaskPatchRequestV1", new CollaborationTaskPatchRequestV1(null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationTaskListV1", new CollaborationTaskListV1(null, null).toSchema()); + schemas.put("CollaborationAssignmentRequestV1", new CollaborationAssignmentRequestV1(null, null).toSchema()); + schemas.put("CollaborationAssignmentV1", new CollaborationAssignmentV1(null, null, null, null, null).toSchema()); + schemas.put("CollaborationMyWorkV1", new CollaborationMyWorkV1(null, null, null, null).toSchema()); + return (schemas); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CreateAccessRequestSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CreateAccessRequestSpec.java new file mode 100644 index 0000000..145fcdc --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CreateAccessRequestSpec.java @@ -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 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()))); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CreateSharesSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CreateSharesSpec.java new file mode 100644 index 0000000..11a2b1a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CreateSharesSpec.java @@ -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 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(new com.kingsrook.qbits.collaboration.sharing.SharingService() + .share(table(input), pk(input), recipients(body.toUserIds()), body.message()).stream().map(CollaborationViews::share).toList())); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CreateTaskSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CreateTaskSpec.java new file mode 100644 index 0000000..fd3f4c7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CreateTaskSpec.java @@ -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 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())))); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/GetMyWorkSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/GetMyWorkSpec.java new file mode 100644 index 0000000..c745655 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/GetMyWorkSpec.java @@ -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 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(), summary.tasks().stream().map(CollaborationViews::task).toList(), + summary.assignedRecordsByTable(), summary.unreadMentionCount())); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/GetRecordLinkSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/GetRecordLinkSpec.java new file mode 100644 index 0000000..1b83d29 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/GetRecordLinkSpec.java @@ -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 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())); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyAccessRequestsSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyAccessRequestsSpec.java new file mode 100644 index 0000000..6143ce6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyAccessRequestsSpec.java @@ -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 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(page.items().stream().map(CollaborationViews::accessRequest).toList(), page.nextCursor())); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyTasksSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyTasksSpec.java new file mode 100644 index 0000000..ae297fd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyTasksSpec.java @@ -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 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(page.tasks().stream().map(CollaborationViews::task).toList(), page.nextCursor())); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListTasksSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListTasksSpec.java new file mode 100644 index 0000000..827e9c2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListTasksSpec.java @@ -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 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(page.items().stream().map(CollaborationViews::task).toList(), page.nextCursor())); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/PatchTaskSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/PatchTaskSpec.java new file mode 100644 index 0000000..6c62759 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/PatchTaskSpec.java @@ -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 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)); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/PreviewSharesSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/PreviewSharesSpec.java new file mode 100644 index 0000000..f5e779c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/PreviewSharesSpec.java @@ -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 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())); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/PutAssignmentSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/PutAssignmentSpec.java new file mode 100644 index 0000000..e8ba262 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/PutAssignmentSpec.java @@ -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 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()))); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java index af381da..d8f6f9d 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java @@ -125,7 +125,7 @@ public final class CollaborationHome .withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true))); html.append("
  • ").append(scope).append(": ").append(enabled).append("
  • "); } - html.append("

    Stored overrides; applying them awaits the core collaboration contracts.

    "); + html.append("

    Feature settings take effect after saving.

    "); } long people = count(CollaborationTables.USER, new QQueryFilter()); long recent = count(CollaborationTables.USER, new QQueryFilter() diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java index c12792d..25d33e0 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java @@ -65,7 +65,7 @@ public class AttachmentCleanup new com.kingsrook.qbits.collaboration.tags.TagsService().onParentsDeleted(table, ids); while(true) { - List rows = AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table) + List 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()) { @@ -73,7 +73,7 @@ public class AttachmentCleanup } for(QRecord row : rows) { - service.deleteStoredRow(row); + service.deleteStoredRow(row, true); } } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentRecords.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentRecords.java index 92075b9..ee97208 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentRecords.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentRecords.java @@ -49,7 +49,7 @@ final class AttachmentRecords *******************************************************************************/ static List 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()); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentSecurity.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentSecurity.java index 792097f..fcb44ef 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentSecurity.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentSecurity.java @@ -25,16 +25,13 @@ import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissionChec import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; 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.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 { @@ -49,18 +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) - { - com.kingsrook.qbits.collaboration.security.ParentTableRestriction.restrict(input); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java index 3acb0eb..c10b958 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java @@ -140,7 +140,7 @@ public class AttachmentsService .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 records = new InsertAction().execute(input).getRecords(); AttachmentRecords.checkErrors(records); @@ -259,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()); @@ -286,10 +286,7 @@ public class AttachmentsService { 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); } @@ -297,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()); } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java index 9076086..de1aead 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java @@ -42,7 +42,7 @@ 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.OtherUserAccess; +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; @@ -233,7 +233,7 @@ public class CommentsService List result = new ArrayList<>(); for(PersonEntry person : people) { - result.add(new MentionCandidate(person, new OtherUserAccess().canRead(person.userId(), table, id))); + result.add(new MentionCandidate(person, new CollaborationRecordAccessChecker().canRead(person.userId(), table, id))); } return (List.copyOf(result)); } @@ -280,7 +280,7 @@ public class CommentsService { continue; } - boolean visible = new OtherUserAccess().canRead(user, table, id).orElse(false); + 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) @@ -358,7 +358,7 @@ public class CommentsService } /******************************************************************************* - ** Shared feature permission stand-in. + ** Shared core record feature permission boundary. *******************************************************************************/ private void requireCommentFeature(QRecord row) throws QException { diff --git a/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowService.java b/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowService.java index 15286cb..d70595a 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowService.java @@ -29,7 +29,7 @@ import com.kingsrook.qbits.collaboration.PersonalRecords; import com.kingsrook.qbits.collaboration.activity.ActivityWriter; import com.kingsrook.qbits.collaboration.model.CollaborationTables; import com.kingsrook.qbits.collaboration.people.PeopleService; -import com.kingsrook.qbits.collaboration.security.OtherUserAccess; +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.QException; @@ -107,7 +107,7 @@ public class FollowService { new PeopleService().touch(); String id = canonicalId(table, recordId); - if(!userId.equals(PersonalRecords.currentUserId()) && !new OtherUserAccess().canRead(userId, table, id).orElse(false)) + if(!userId.equals(PersonalRecords.currentUserId()) && !new CollaborationRecordAccessChecker().canRead(userId, table, id).orElse(false)) { return; } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java index bd5aaad..03adfd0 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java @@ -53,7 +53,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; /******************************************************************************* - ** Visible Collaboration tables. Enum ids are phase-a stand-ins for the core + ** Visible Collaboration tables. Enum ids use the names of the core ** collaboration enums; replace them when those contracts are released. *******************************************************************************/ public final class CollaborationTables @@ -147,6 +147,9 @@ public final class CollaborationTables .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)) .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "scopeType", "scopeName", "feature")).withLabel("Identity")) .withSection(new QFieldSection("details", new QIcon("settings"), Tier.T2, List.of("enabled", "settings", "permissionMode")).withLabel("Details")); + settings.withCustomizer(TableCustomizers.POST_INSERT_RECORD, new QCodeReference(com.kingsrook.qbits.collaboration.FeatureSettingsCustomizer.class)) + .withCustomizer(TableCustomizers.POST_UPDATE_RECORD, new QCodeReference(com.kingsrook.qbits.collaboration.FeatureSettingsCustomizer.class)) + .withCustomizer(TableCustomizers.POST_DELETE_RECORD, new QCodeReference(com.kingsrook.qbits.collaboration.FeatureSettingsCustomizer.class)); QTableMetaData people = base(config, USER, "People", "people") .withRecordLabelFormatAndFields("%s", "displayName") .withField(string("userId", "User ID", 255).withIsRequired(true)) @@ -177,6 +180,7 @@ public final class CollaborationTables for(QTableMetaData table : tables) { table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates")); + com.kingsrook.qbits.collaboration.CoreCollaboration.reference(table, config); QInstanceEnricher.setInferredFieldBackendNames(table); } return (tables); @@ -382,6 +386,8 @@ public final class CollaborationTables .withSection(new QFieldSection("details", new QIcon("label"), Tier.T1, List.of("id", "name", "label", "color", "tableName", "approved", "description", "createdByUserId", "usageCount", "mergedIntoTagId")).withLabel("Details")); QTableMetaData links = referenced(config, RECORD_TAG, "Record Tags", "label") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED) + .withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.security.CollaborationRowSecurity.class))) .withField(new QFieldMetaData("tagId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(TAG)) .withField(string("createdByUserId", "Created By", 255).withIsRequired(true)) .withUniqueKey(new UniqueKey("tableName", "recordId", "tagId")) diff --git a/src/main/java/com/kingsrook/qbits/collaboration/mywork/MyWorkService.java b/src/main/java/com/kingsrook/qbits/collaboration/mywork/MyWorkService.java index cbe3a62..6697ba5 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/mywork/MyWorkService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/mywork/MyWorkService.java @@ -96,7 +96,7 @@ public class MyWorkService } /******************************************************************************* - ** Stable service contract for the subsequent UI API PR. + ** Stable service contract projected by the My work UI API. *******************************************************************************/ public record Summary(Long openTaskCount, List tasks, Map assignedRecordsByTable, Long unreadMentionCount) { diff --git a/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java b/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java index d47caa2..e85cac4 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java @@ -22,7 +22,7 @@ package com.kingsrook.qbits.collaboration.notify; import com.kingsrook.qbits.collaboration.CollaborationRecords; -import com.kingsrook.qbits.collaboration.security.OtherUserAccess; +import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.context.CapturedContext; import com.kingsrook.qqq.backend.core.context.QContext; @@ -30,7 +30,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeVoidVoidMethod; /******************************************************************************* - ** Notification delivery uses RC.5 after-commit callbacks and fresh recipient access. + ** Notification delivery uses core after-commit callbacks and fresh recipient access. ** replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released *******************************************************************************/ public final class Notifications @@ -97,10 +97,23 @@ public final class Notifications *******************************************************************************/ private static void deliver(CollaborationNotification notification, UnsafeVoidVoidMethod receipt, boolean checkParent) throws QException { - if(!checkParent || new OtherUserAccess().canRead(notification.recipientUserId(), notification.sourceTableName(), notification.sourceRecordId()).orElse(false)) + if(!checkParent || new CollaborationRecordAccessChecker().canRead(notification.recipientUserId(), notification.sourceTableName(), notification.sourceRecordId()).orElse(false)) { CollaborationNotifier notifier = QCodeLoader.getAdHoc(CollaborationNotifier.class, CollaborationRecords.config().getNotifier()); notifier.notify(notification); + if(!(notifier instanceof NoOpNotifier)) + { + try + { + com.kingsrook.qqq.backend.core.realtime.RealtimeBus.get(QContext.getQInstance()).publish( + new com.kingsrook.qqq.backend.core.realtime.RealtimeMessage().withChannel("user:" + notification.recipientUserId()) + .withEvent("notification.created").withData(java.util.Map.of("userId", notification.recipientUserId()))); + } + catch(Exception e) + { + com.kingsrook.qqq.backend.core.logging.QLogger.getLogger(Notifications.class).warn("Could not publish notification hint", e); + } + } if(receipt != null && !(notifier instanceof NoOpNotifier)) { receipt.run(); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/OtherUserAccess.java b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRecordAccessChecker.java similarity index 73% rename from src/main/java/com/kingsrook/qbits/collaboration/security/OtherUserAccess.java rename to src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRecordAccessChecker.java index 808cebd..204fce7 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/security/OtherUserAccess.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRecordAccessChecker.java @@ -34,22 +34,31 @@ import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; import com.kingsrook.qqq.backend.core.model.session.QSession; /******************************************************************************* - ** Stand-in for core RecordAccessCheckerInterface. Unknown is never a grant. + ** Core other-user access checker. Unknown is never a grant. *******************************************************************************/ -public class OtherUserAccess +public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backend.core.actions.collaboration.RecordAccessCheckerInterface { + /******************************************************************************* + ** Core reference-based access contract. + *******************************************************************************/ + @Override + public Optional canRead(String userId, com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference ref) throws QException + { + return (canRead(userId, ref.tableName(), ref.primaryKey())); + } + /******************************************************************************* ** The temporary context has no inherited permission bypass or caller transaction. *******************************************************************************/ public Optional canRead(String userId, String table, Serializable recordId) throws QException { CollaborationQBitConfig config = CollaborationRecords.config(); - if(config.getUserSessionSupplier() == null) + if(config.getUserSessionProvider() == null) { return (Optional.empty()); } - UserSessionSupplier supplier = QCodeLoader.getAdHoc(UserSessionSupplier.class, config.getUserSessionSupplier()); - Optional session = supplier.sessionFor(userId); + com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface supplier = QCodeLoader.getAdHoc(com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface.class, config.getUserSessionProvider()); + Optional session = supplier.sessionForUser(userId); if(session.isEmpty()) { return (Optional.empty()); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java index cfd7e48..fdb9bb7 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java @@ -28,7 +28,6 @@ import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecke 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.update.UpdateInput; @@ -50,16 +49,21 @@ public class CollaborationRowSecurity implements CustomPermissionChecker { throw (new QPermissionDeniedException("Use the collaboration service actions.")); } - new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata); + if(input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput query && query.getFieldNamesToInclude() != null) + { + var fields = new java.util.HashSet<>(query.getFieldNamesToInclude()); + fields.addAll(java.util.List.of("id", "tableName", "recordId", "parentDeleted")); + query.setFieldNamesToInclude(fields); + } + if(!(metadata instanceof QTableMetaData table && table.getName().equals(CollaborationTables.RECORD_TAG))) + { + new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata); + } if(metadata instanceof QTableMetaData table && (table.getName().equals(CollaborationTables.RECORD_ASSIGNMENT) || table.getName().equals(CollaborationTables.FOLLOW) || table.getName().equals(CollaborationTables.ACTIVITY) || table.getName().equals(CollaborationTables.FOLLOW_DIGEST)) && !QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN)) { throw (new QPermissionDeniedException("This needs collaboration.admin.")); } - if(input instanceof QueryOrCountInputInterface query) - { - ParentTableRestriction.restrict(query); - } } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java index 652785a..3d08b7a 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java @@ -23,23 +23,18 @@ package com.kingsrook.qbits.collaboration.security; import java.io.Serializable; import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; -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.tables.GetAction; 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.exceptions.QPermissionDeniedException; -import com.kingsrook.qqq.backend.core.exceptions.QValueException; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; -import com.kingsrook.qqq.backend.core.utils.ValueUtils; /******************************************************************************* - ** Stand-in for QQQ core RecordReferenceSecurityHelper (QRun-IO/qqq collaboration phase a); replace when released + ** Thin service boundary over core parent visibility and record capabilities. *******************************************************************************/ public final class ParentRecordAccess { @@ -69,32 +64,34 @@ public final class ParentRecordAccess { throw (new QNotFoundException("Not found")); } - QTableMetaData table = QContext.getQInstance().getTable(tableName); try { - Serializable key = ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), recordId); - GetInput input = new GetInput(tableName).withPrimaryKey(key); - input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); - QRecord parent = new GetAction().execute(input).getRecord(); + var table = QContext.getQInstance().getTable(tableName); + Serializable key = com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), recordId); + QRecord parent = com.kingsrook.qqq.backend.core.actions.collaboration.RecordReferenceSecurityHelper.visibleParent(tableName, key, QContext.getQBackendTransaction()); if(parent == null) { throw (new QNotFoundException("Not found")); } return (parent); } - catch(QPermissionDeniedException | QValueException expected) + catch(com.kingsrook.qqq.backend.core.exceptions.QValueException e) { throw (new QNotFoundException("Not found")); } } /******************************************************************************* - ** Temporary approximation: EDIT plus a visible parent, not WRITE lock checks. + ** Core record capability checks include parent WRITE locks for implied grants. *******************************************************************************/ public static void requireFeature(String tableName, Serializable recordId, String feature) throws QException { - requireVisibleParent(tableName, recordId); - requireTableFeature(tableName, feature); + QRecord parent = requireVisibleParent(tableName, recordId); + if(!com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturePermissions.checkRecordCapability( + QContext.getQInstance().getTable(tableName), feature(feature), parent, QContext.getQBackendTransaction())) + { + throw (new QPermissionDeniedException("Permission denied for " + feature + ".")); + } } /******************************************************************************* @@ -106,13 +103,15 @@ public final class ParentRecordAccess { throw (new QNotFoundException("Not found")); } - QTableMetaData table = QContext.getQInstance().getTable(tableName); - String base = table.getPermissionRules() == null ? null : table.getPermissionRules().getPermissionBaseName(); - base = base == null || base.isBlank() ? tableName : base; - if(!QContext.getQSession().hasPermission(tableName + "." + feature) && !QContext.getQSession().hasPermission(base + "." + feature) - && !PermissionsHelper.hasTablePermission(new AbstractActionInput(), tableName, TablePermissionSubType.EDIT)) - { - throw (new QPermissionDeniedException("This needs the " + base + "." + feature + " permission or table EDIT.")); - } + com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturePermissions.requireTableCapability(QContext.getQInstance().getTable(tableName), feature(feature)); + } + + /******************************************************************************* + ** Map service operation suffixes to the core enum. + *******************************************************************************/ + private static com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature feature(String suffix) + { + return (java.util.Arrays.stream(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature.values()) + .filter(value -> suffix.equals(value.getPermissionSuffix())).findFirst().orElseThrow()); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentTableRestriction.java b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentTableRestriction.java deleted file mode 100644 index a52aeec..0000000 --- a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentTableRestriction.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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.security; - -import java.util.List; -import com.kingsrook.qqq.backend.core.context.QContext; -import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrCountInputInterface; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; - -/******************************************************************************* - ** Shared pre-query parent-table restriction. Record locks require post-filtering. - *******************************************************************************/ -public final class ParentTableRestriction -{ - /******************************************************************************* - ** Static helper. - *******************************************************************************/ - private ParentTableRestriction() - { - } - - /******************************************************************************* - ** AND outside caller OR clauses while retaining paging and sort order. - *******************************************************************************/ - public static void restrict(QueryOrCountInputInterface input) - { - List 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); - } -} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/UserSessionSupplier.java b/src/main/java/com/kingsrook/qbits/collaboration/security/UserSessionSupplier.java deleted file mode 100644 index 2c3b8b0..0000000 --- a/src/main/java/com/kingsrook/qbits/collaboration/security/UserSessionSupplier.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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.security; - -import java.util.Optional; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.session.QSession; - -/******************************************************************************* - ** Stand-in for the unreleased core UserSessionProviderInterface. - *******************************************************************************/ -public interface UserSessionSupplier -{ - /******************************************************************************* - ** Return a freshly authorized session, or unknown when identity is unavailable. - *******************************************************************************/ - Optional sessionFor(String userId) throws QException; -} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/sharing/AccessRequestService.java b/src/main/java/com/kingsrook/qbits/collaboration/sharing/AccessRequestService.java index 2cae80f..794683c 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/sharing/AccessRequestService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/sharing/AccessRequestService.java @@ -25,7 +25,6 @@ import java.io.Serializable; import java.time.Instant; import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import com.kingsrook.qbits.collaboration.CollaborationRecords; @@ -45,7 +44,6 @@ import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QRecord; -import com.kingsrook.qqq.backend.core.utils.JsonUtils; /******************************************************************************* ** Requests never inspect the parent. Their public result reveals no existence data. @@ -108,25 +106,14 @@ public class AccessRequestService *******************************************************************************/ public String approverPermission(String table) throws QException { - for(QRecord setting : CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter() - .withCriteria("scopeType", QCriteriaOperator.EQUALS, "TABLE").withCriteria("scopeName", QCriteriaOperator.EQUALS, table) - .withCriteria("feature", QCriteriaOperator.EQUALS, "SHARING"))) + var metadata = QContext.getQInstance().getTable(table); + if(metadata != null) { - String json = setting.getValueString("settings"); - if(json != null && !json.isBlank()) + Object permission = com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver.resolve(QContext.getQInstance(), metadata) + .getFeatures().get(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature.SHARING).getSettings().get("accessApproverPermission"); + if(permission != null && !permission.toString().isBlank()) { - try - { - Object permission = JsonUtils.toObject(json, Map.class).get("accessApproverPermission"); - if(permission != null && !permission.toString().isBlank()) - { - return (permission.toString()); - } - } - catch(Exception e) - { - throw (new QException("Invalid SHARING settings JSON", e)); - } + return (permission.toString()); } } return (CollaborationPermissions.ADMIN); @@ -234,4 +221,14 @@ public class AccessRequestService Notifications.sendAccessNotice(new CollaborationNotification(user, title, "Review the access request.", CollaborationNotification.Category.SHARE, null, null, "/app/collaboration_access_request/" + id)); } + + /******************************************************************************* + ** List only the current requester's submissions without inspecting parents. + *******************************************************************************/ + public List mine() throws QException + { + return (CollaborationRecords.query(CollaborationTables.ACCESS_REQUEST, new QQueryFilter() + .withCriteria("requesterUserId", QCriteriaOperator.EQUALS, PersonalRecords.currentUserId()) + .withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("id", false)))); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java b/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java index 9af07fa..90c8f20 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java @@ -36,7 +36,7 @@ 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.security.OtherUserAccess; +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.QException; @@ -72,7 +72,7 @@ public class SharingService List recipients = new ArrayList<>(); for(String user : toUserIds.stream().distinct().toList()) { - Optional access = user.equals(PersonalRecords.currentUserId()) ? Optional.of(true) : new OtherUserAccess().canRead(user, table, recordId); + Optional access = user.equals(PersonalRecords.currentUserId()) ? Optional.of(true) : new CollaborationRecordAccessChecker().canRead(user, table, recordId); recipients.add(new Recipient(user, new PeopleService().displayName(user), access.orElse(null))); } return (recipients); @@ -127,7 +127,7 @@ public class SharingService } if(row.getValueInstant("openedAt") == null) { - CollaborationRecords.update(CollaborationTables.SHARE, new QRecord().withValue("id", shareId).withValue("openedAt", Instant.now())); + CollaborationRecords.acknowledgeShare(shareId, Instant.now()); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagCriteriaExpander.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagCriteriaExpander.java new file mode 100644 index 0000000..4af0fd1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagCriteriaExpander.java @@ -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.tags; + +import java.io.Serializable; +import java.util.List; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qqq.backend.core.actions.tables.query.CriteriaExpansionContext; +import com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind; +import com.kingsrook.qqq.backend.core.actions.tables.query.VirtualFieldCriteriaExpanderInterface; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QSubQuery; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + +/******************************************************************************* + ** Tag membership uses core subqueries, preserving parent query security and paging. + *******************************************************************************/ +public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterface +{ + /******************************************************************************* + ** Complements exclude records with any matching association. + *******************************************************************************/ + @Override + public QQueryFilter expand(QTableMetaData table, QFilterCriteria criteria, CriteriaExpansionContext context) throws QException + { + var operator = criteria.getOperator(); + if(operator == QCriteriaOperator.CONTAINS_ALL) + { + QQueryFilter all = new QQueryFilter(); + for(Serializable value : criteria.getValues()) + { + if(value == null) + { + throw (new QUserFacingException("Tags cannot contain null values")); + } + all.withSubFilter(membership(table, List.of(value), false, false)); + } + return (all); + } + return (switch(operator) + { + case EQUALS, IN -> membership(table, criteria.getValues(), false, false); + case NOT_EQUALS, NOT_IN -> membership(table, criteria.getValues(), true, false); + case IS_BLANK -> membership(table, List.of(), true, true); + case IS_NOT_BLANK -> membership(table, List.of(), false, true); + default -> throw new QUserFacingException("Unsupported Tags operator: " + operator); + }); + } + + /******************************************************************************* + ** Numeric values are picker IDs; strings are exact normalized vocabulary names. + *******************************************************************************/ + private QQueryFilter membership(QTableMetaData table, List values, boolean negate, boolean any) throws QException + { + QQueryFilter links = links(table); + if(!any) + { + QQueryFilter tags = TagsService.vocabularyFilter(table.getName()).withCriteria("approved", QCriteriaOperator.EQUALS, true); + QQueryFilter choices = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR); + if(values.isEmpty()) + { + choices.withCriteria("id", QCriteriaOperator.IN, List.of()); + } + for(Serializable value : values) + { + if(value == null) + { + throw (new QUserFacingException("Tags cannot contain null values")); + } + choices.withCriteria(value instanceof Number ? "id" : "name", QCriteriaOperator.EQUALS, + value instanceof Number ? value : TagsService.normalize(value.toString())); + } + tags.withSubFilter(choices); + links.withCriteria("tagId", QCriteriaOperator.IN, new QSubQuery(CollaborationTables.TAG, "id", tags)); + } + return (new QQueryFilter().withCriteria(table.getPrimaryKeyField(), negate ? QCriteriaOperator.NOT_IN : QCriteriaOperator.IN, + new QSubQuery(CollaborationTables.RECORD_TAG, "recordId", links))); + } + + /******************************************************************************* + ** Only live associations on this parent table participate. + *******************************************************************************/ + private QQueryFilter links(QTableMetaData table) + { + return (new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()) + .withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false)); + } + + /******************************************************************************* + ** Ordinary search matches labels; hash search matches an exact normalized name. + *******************************************************************************/ + @Override + public QQueryFilter expandSearchTerm(QTableMetaData table, String term, SearchTermKind kind, CriteriaExpansionContext context) throws QException + { + QQueryFilter tags = TagsService.vocabularyFilter(table.getName()).withCriteria("approved", QCriteriaOperator.EQUALS, true) + .withCriteria(kind == SearchTermKind.TAG ? "name" : "label", kind == SearchTermKind.TAG ? QCriteriaOperator.EQUALS : QCriteriaOperator.CONTAINS, + kind == SearchTermKind.TAG ? TagsService.normalize(term) : term); + return (new QQueryFilter().withCriteria(table.getPrimaryKeyField(), QCriteriaOperator.IN, + new QSubQuery(CollaborationTables.RECORD_TAG, "recordId", links(table) + .withCriteria("tagId", QCriteriaOperator.IN, new QSubQuery(CollaborationTables.TAG, "id", tags))))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagFilterTranslator.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagFilterTranslator.java deleted file mode 100644 index 9ed3b9e..0000000 --- a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagFilterTranslator.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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.tags; - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import com.kingsrook.qbits.collaboration.CollaborationRecords; -import com.kingsrook.qbits.collaboration.model.CollaborationTables; -import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; -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.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.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.data.QRecord; - -/******************************************************************************* - ** Materialized tag filter logic independent of the unreleased core hooks in #1140. - ** Every operator returns matching caller-visible parent ids, including complements. - *******************************************************************************/ -public class TagFilterTranslator -{ - public static final int MAX_RECORD_IDS = 10000; - - /******************************************************************************* - ** IN means any tag, CONTAINS_ALL means every requested tag, NOT_IN means none. - ** Empty IN matches none; empty CONTAINS_ALL and NOT_IN match all visible rows. - *******************************************************************************/ - public List recordIds(String table, String op, Collection tags) throws QException - { - TagsService.requireTable(table); - if(!List.of("IN", "CONTAINS_ALL", "NOT_IN", "IS_BLANK", "IS_NOT_BLANK").contains(op)) - { - throw (new QException("Unsupported tag operator: " + op)); - } - Set requested = new HashSet<>(); - boolean missing = false; - if(!op.equals("IS_BLANK") && !op.equals("IS_NOT_BLANK")) - { - for(Serializable tag : tags == null ? List.of() : tags) - { - QQueryFilter filter = TagsService.vocabularyFilter(table).withCriteria("approved", QCriteriaOperator.EQUALS, true); - filter.withCriteria(tag instanceof Number ? "id" : "name", QCriteriaOperator.EQUALS, - tag instanceof Number ? tag : TagsService.normalize(tag == null ? null : tag.toString())); - List matches = CollaborationRecords.query(CollaborationTables.TAG, filter.withOrderBy(new QFilterOrderBy("id"))); - if(matches.isEmpty()) - { - missing = true; - } - else - { - QRecord match = matches.stream().filter(row -> row.getValue("tableName") == null).findFirst().orElse(matches.getFirst()); - requested.add(match.getValueInteger("id")); - } - } - } - Map> assignments = new HashMap<>(); - for(QRecord row : CollaborationRecords.query(CollaborationTables.RECORD_TAG, new QQueryFilter() - .withCriteria("tableName", QCriteriaOperator.EQUALS, table).withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false))) - { - assignments.computeIfAbsent(row.getValueString("recordId"), ignored -> new HashSet<>()).add(row.getValueInteger("tagId")); - } - List result = new ArrayList<>(); - String key = QContext.getQInstance().getTable(table).getPrimaryKeyField(); - for(int skip = 0; ; skip += 500) - { - QueryInput input = new QueryInput(table).withFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy(key)).withSkip(skip).withLimit(500)) - .withTransaction(QContext.getQBackendTransaction()); - input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); - List parents = new QueryAction().execute(input).getRecords(); - for(QRecord parent : parents) - { - String id = parent.getValueString(key); - Set applied = assignments.getOrDefault(id, Set.of()); - boolean any = requested.stream().anyMatch(applied::contains); - boolean match = switch(op) - { - case "IN" -> any; - case "NOT_IN" -> !any; - case "CONTAINS_ALL" -> !missing && applied.containsAll(requested); - case "IS_BLANK" -> applied.isEmpty(); - default -> !applied.isEmpty(); - }; - if(match) - { - result.add(id); - if(result.size() > MAX_RECORD_IDS) - { - throw (new QException("Tag filter exceeds 10,000 matching record ids.")); - } - } - } - if(parents.size() < 500) - { - return (result); - } - } - } -} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsMetaDataProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsMetaDataProvider.java new file mode 100644 index 0000000..65f5946 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsMetaDataProvider.java @@ -0,0 +1,121 @@ +/* + * 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.tags; + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.collaboration.FeatureSettingsProvider; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataProviderInterface; +import com.kingsrook.qqq.backend.core.exceptions.QException; +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.RecordFeatureOverride; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData; +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.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + +/******************************************************************************* + ** Contribute the Tags filter and quick-search field without changing host hooks. + *******************************************************************************/ +public class TagsMetaDataProvider implements RuntimeMetaDataProviderInterface +{ + /******************************************************************************* + ** Overlay providers run before the new feature snapshot is published. + *******************************************************************************/ + @Override + public RuntimeMetaDataContribution getContribution(QInstance instance) throws QException + { + var contribution = new RuntimeMetaDataContribution(); + var overrides = new FeatureSettingsProvider().getOverrides(instance); + for(var table : instance.getTables().values()) + { + if(!table.getName().startsWith("collaboration_") && table.getPrimaryKeyField() != null + && (table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_GET) || table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_QUERY)) + && enabled(instance, table, overrides)) + { + contribution.getTables().put(table.getName(), new RuntimeTableMetaDataContribution() + .withVirtualFields(List.of(new QVirtualFieldMetaData("collaborationTags", QFieldType.STRING).withLabel("Tags") + .withPossibleValueSourceName(CollaborationTables.TAG).withIsQueryCriteria(true).withIsQuerySelectable(false) + .withCriteriaExpander(new QCodeReference(TagCriteriaExpander.class)))) + .withSearchFields(List.of("collaborationTags"))); + } + } + return (contribution); + } + + /******************************************************************************* + ** Resolve TAGS against the incoming policy in core's instance/app/table order. + *******************************************************************************/ + private boolean enabled(QInstance instance, QTableMetaData table, List overrides) + { + List levels = new ArrayList<>(); + List 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(RecordFeature.TAGS))) + { + return (false); + } + enabled = level.getEnabled().getOrDefault(RecordFeature.TAGS, enabled); + } + } + for(var scope : RecordFeatureOverride.ScopeType.values()) + { + List 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() == RecordFeature.TAGS && 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java index da3b710..df185d7 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java @@ -34,7 +34,6 @@ import com.kingsrook.qbits.collaboration.PersonalRecords; import com.kingsrook.qbits.collaboration.activity.ActivityWriter; import com.kingsrook.qbits.collaboration.model.CollaborationTables; import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; -import com.kingsrook.qqq.backend.core.context.CapturedContext; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -44,8 +43,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperat 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.session.QSystemUserSession; -import com.kingsrook.qqq.backend.core.utils.JsonUtils; /******************************************************************************* ** Tag vocabulary and assignments. Parent authorization precedes trusted storage. @@ -100,65 +97,18 @@ public class TagsService } /******************************************************************************* - ** Resolve persisted policy as system, preserving the caller and transaction. - ** The preferred app path supplies the APP scope when a table has several apps. + ** Read core's committed policy with instance/app/table resolution and hard limits. *******************************************************************************/ public String mode(String table) throws QException { - List resolved = new ArrayList<>(); - QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), new QSystemUserSession(), QContext.getQBackendTransaction(), null), () -> + Object setting = com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver.resolve(QContext.getQInstance(), QContext.getQInstance().getTable(table)) + .getFeatures().get(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature.TAGS).getSettings().get("mode"); + String mode = setting == null ? CollaborationRecords.config().getDefaultTagMode() : setting.toString(); + if(!List.of("FREE", "APPROVED_ONLY").contains(mode)) { - String path = QContext.getQInstance().getTablePath(table); - String[] parts = path == null ? new String[0] : path.split("/"); - String app = parts.length > 2 ? parts[parts.length - 2] : null; - for(String scope : List.of("TABLE", "APP", "INSTANCE")) - { - if(scope.equals("APP") && app == null) - { - continue; - } - QQueryFilter filter = new QQueryFilter().withCriteria("feature", QCriteriaOperator.EQUALS, "TAGS") - .withCriteria("scopeType", QCriteriaOperator.EQUALS, scope); - if(!scope.equals("INSTANCE")) - { - filter.withCriteria("scopeName", QCriteriaOperator.EQUALS, scope.equals("TABLE") ? table : app); - } - for(QRecord row : CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, filter)) - { - String json = row.getValueString("settings"); - if(json != null && !json.isBlank()) - { - Object value = settingMode(json); - if(value != null) - { - String mode = value.toString(); - if(!List.of("FREE", "APPROVED_ONLY").contains(mode)) - { - throw (new QException("Invalid TAGS mode: " + mode)); - } - resolved.add(mode); - return; - } - } - } - } - }); - return (resolved.isEmpty() ? CollaborationRecords.config().getDefaultTagMode() : resolved.getFirst()); - } - - /******************************************************************************* - ** Invalid JSON fails closed instead of silently relaxing an approval policy. - *******************************************************************************/ - private Object settingMode(String json) throws QException - { - try - { - return (JsonUtils.toObject(json, Map.class).get("mode")); - } - catch(Exception e) - { - throw (new QException("Invalid TAGS settings JSON", e)); + throw (new QException("Invalid TAGS mode: " + mode)); } + return (mode); } /******************************************************************************* @@ -368,10 +318,10 @@ public class TagsService { synchronized(CollaborationRecords.config()) { - for(QRecord link : CollaborationRecords.query(CollaborationTables.RECORD_TAG, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table) + for(QRecord link : CollaborationRecords.cleanupQuery(CollaborationTables.RECORD_TAG, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table) .withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()))) { - CollaborationRecords.delete(CollaborationTables.RECORD_TAG, link.getValue("id")); + CollaborationRecords.cleanupDelete(CollaborationTables.RECORD_TAG, link.getValue("id")); recompute(link.getValue("tagId")); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java b/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java index 44718f0..20dcfad 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java @@ -204,7 +204,7 @@ public class TasksService int size = limit == null ? 20 : limit; if(size < 1 || size > 100) { - throw (new QException("Limit must be between 1 and 100.")); + throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Limit must be between 1 and 100.")); } List rows = mineRows(status); if(cursor != null) @@ -224,7 +224,7 @@ public class TasksService String selected = status == null ? "OPEN" : status; if(!List.of("OPEN", "DONE", "CANCELLED").contains(selected)) { - throw (new QException("Unknown task status.")); + throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Unknown task status.")); } List visible = new ArrayList<>(); for(QRecord row : CollaborationRecords.query(CollaborationTables.TASK, new QQueryFilter() @@ -306,7 +306,7 @@ public class TasksService } catch(RuntimeException e) { - throw (new QException("Invalid cursor", e)); + throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("Invalid cursor", e)); } } diff --git a/src/main/resources/openapi/qqq-v1/collaboration.yaml b/src/main/resources/openapi/qqq-v1/collaboration.yaml index d209c2a..ef36c78 100644 --- a/src/main/resources/openapi/qqq-v1/collaboration.yaml +++ b/src/main/resources/openapi/qqq-v1/collaboration.yaml @@ -1,5 +1,107 @@ components: schemas: + CollaborationAccessRequestCreateRequestV1: + properties: + message: + description: "message." + nullable: true + type: "string" + recordId: + description: "recordId." + nullable: true + type: "string" + shareId: + description: "shareId." + nullable: true + type: "integer" + tableName: + description: "tableName." + nullable: true + type: "string" + type: "object" + CollaborationAccessRequestListV1: + properties: + items: + description: "items." + items: + $ref: "#/components/schemas/CollaborationAccessRequestV1" + nullable: true + type: "array" + nextCursor: + description: "nextCursor." + nullable: true + type: "string" + type: "object" + CollaborationAccessRequestV1: + properties: + handledAt: + description: "handledAt." + format: "date-time" + nullable: true + type: "string" + id: + description: "id." + nullable: true + type: "integer" + message: + description: "message." + nullable: true + type: "string" + note: + description: "note." + nullable: true + type: "string" + recordId: + description: "recordId." + nullable: true + type: "string" + requesterUserId: + description: "requesterUserId." + nullable: true + type: "string" + status: + description: "status." + nullable: true + type: "string" + tableName: + description: "tableName." + nullable: true + type: "string" + type: "object" + CollaborationAssignmentRequestV1: + properties: + assigneeUserId: + description: "assigneeUserId." + nullable: true + type: "string" + ownerUserId: + description: "ownerUserId." + nullable: true + type: "string" + type: "object" + CollaborationAssignmentV1: + properties: + assigneeUserId: + description: "assigneeUserId." + nullable: true + type: "string" + id: + description: "id." + nullable: true + type: "integer" + ownerUserId: + description: "ownerUserId." + nullable: true + type: "string" + recordId: + description: "recordId." + nullable: true + type: "string" + tableName: + description: "tableName." + nullable: true + type: "string" + type: "object" CollaborationAttachmentListV1: description: "Attachments, newest first." properties: @@ -412,6 +514,17 @@ components: nullable: true type: "string" type: "object" + CollaborationLinkV1: + properties: + label: + description: "label." + nullable: true + type: "string" + url: + description: "url." + nullable: true + type: "string" + type: "object" CollaborationMentionCandidateListV1: description: "Candidates ranked with record participants first." properties: @@ -464,6 +577,29 @@ components: nullable: true type: "boolean" type: "object" + CollaborationMyWorkV1: + properties: + assignedRecordsByTable: + additionalProperties: + type: "integer" + description: "assignedRecordsByTable." + nullable: true + type: "object" + openTaskCount: + description: "openTaskCount." + nullable: true + type: "integer" + tasks: + description: "tasks." + items: + $ref: "#/components/schemas/CollaborationTaskV1" + nullable: true + type: "array" + unreadMentionCount: + description: "unreadMentionCount." + nullable: true + type: "integer" + type: "object" CollaborationPersonListV1: description: "Matching active directory entries." properties: @@ -515,6 +651,88 @@ components: nullable: true type: "string" type: "object" + CollaborationShareListV1: + properties: + items: + description: "items." + items: + $ref: "#/components/schemas/CollaborationShareV1" + nullable: true + type: "array" + type: "object" + CollaborationSharePreviewV1: + properties: + recipients: + description: "recipients." + items: + $ref: "#/components/schemas/CollaborationShareRecipientV1" + nullable: true + type: "array" + type: "object" + CollaborationShareRecipientV1: + properties: + canRead: + description: "canRead." + nullable: true + type: "boolean" + displayName: + description: "displayName." + nullable: true + type: "string" + userId: + description: "userId." + nullable: true + type: "string" + type: "object" + CollaborationShareRequestV1: + properties: + message: + description: "message." + nullable: true + type: "string" + toUserIds: + description: "toUserIds." + items: + type: "string" + nullable: true + type: "array" + type: "object" + CollaborationShareV1: + properties: + fromUserId: + description: "fromUserId." + nullable: true + type: "string" + id: + description: "id." + nullable: true + type: "integer" + message: + description: "message." + nullable: true + type: "string" + recipientCouldRead: + description: "recipientCouldRead." + nullable: true + type: "boolean" + recordId: + description: "recordId." + nullable: true + type: "string" + sentAt: + description: "sentAt." + format: "date-time" + nullable: true + type: "string" + tableName: + description: "tableName." + nullable: true + type: "string" + toUserId: + description: "toUserId." + nullable: true + type: "string" + type: "object" CollaborationTagApplyRequestV1: description: "Add and remove tag associations in one request." properties: @@ -617,6 +835,127 @@ components: nullable: true type: "string" type: "object" + CollaborationTaskCreateRequestV1: + properties: + assigneeUserId: + description: "assigneeUserId." + nullable: true + type: "string" + dueAt: + description: "dueAt." + format: "date-time" + nullable: true + type: "string" + notes: + description: "notes." + nullable: true + type: "string" + remindAt: + description: "remindAt." + format: "date-time" + nullable: true + type: "string" + title: + description: "title." + nullable: true + type: "string" + type: "object" + CollaborationTaskListV1: + properties: + items: + description: "items." + items: + $ref: "#/components/schemas/CollaborationTaskV1" + nullable: true + type: "array" + nextCursor: + description: "nextCursor." + nullable: true + type: "string" + type: "object" + CollaborationTaskPatchRequestV1: + properties: + assigneeUserId: + description: "assigneeUserId." + nullable: true + type: "string" + dueAt: + description: "dueAt." + format: "date-time" + nullable: true + type: "string" + notes: + description: "notes." + nullable: true + type: "string" + remindAt: + description: "remindAt." + format: "date-time" + nullable: true + type: "string" + status: + description: "status." + nullable: true + type: "string" + title: + description: "title." + nullable: true + type: "string" + type: "object" + CollaborationTaskV1: + properties: + assigneeUserId: + description: "assigneeUserId." + nullable: true + type: "string" + completedAt: + description: "completedAt." + format: "date-time" + nullable: true + type: "string" + completedByUserId: + description: "completedByUserId." + nullable: true + type: "string" + createdByUserId: + description: "createdByUserId." + nullable: true + type: "string" + dueAt: + description: "dueAt." + format: "date-time" + nullable: true + type: "string" + id: + description: "id." + nullable: true + type: "integer" + notes: + description: "notes." + nullable: true + type: "string" + recordId: + description: "recordId." + nullable: true + type: "string" + remindAt: + description: "remindAt." + format: "date-time" + nullable: true + type: "string" + status: + description: "status." + nullable: true + type: "string" + tableName: + description: "tableName." + nullable: true + type: "string" + title: + description: "title." + nullable: true + type: "string" + type: "object" CollaborationTimelineDetailV1: description: "A permitted field change from a core audit." properties: @@ -2372,3 +2711,605 @@ paths: - sessionUuidCookie: - "N/A" summary: "Get an active directory person" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/link: + get: + description: "Get a record link. Uses the signed-in caller's permissions and\ + \ record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationLinkV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Get a record link" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/shares/preview: + post: + description: "Preview share recipients. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationShareRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationSharePreviewV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Preview share recipients" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/shares: + post: + description: "Share a record. Uses the signed-in caller's permissions and record\ + \ visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationShareRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationShareListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Share a record" + /qqq/v1/qbit/collaboration/access-requests: + post: + description: "Request access to a record. Uses the signed-in caller's permissions\ + \ and record visibility." + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationAccessRequestCreateRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationAccessRequestV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Request access to a record" + /qqq/v1/qbit/collaboration/access-requests/mine: + get: + description: "List my access requests. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationAccessRequestListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "List my access requests" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/tasks: + get: + description: "List record tasks. Uses the signed-in caller's permissions and\ + \ record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + - description: "Include completed and cancelled tasks; default false." + in: "query" + name: "includeDone" + required: false + schema: + type: "boolean" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTaskListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "List record tasks" + post: + description: "Create a task. Uses the signed-in caller's permissions and record\ + \ visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTaskCreateRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTaskV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Create a task" + /qqq/v1/qbit/collaboration/tasks/{id}: + patch: + description: "Update a task. Uses the signed-in caller's permissions and record\ + \ visibility." + parameters: + - description: "Collaboration object identifier." + in: "path" + name: "id" + required: true + schema: + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTaskPatchRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTaskV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Update a task" + /qqq/v1/qbit/collaboration/tasks/mine: + get: + description: "List my tasks. Uses the signed-in caller's permissions and record\ + \ visibility." + parameters: + - description: "Task status: OPEN (default), DONE or CANCELLED." + in: "query" + name: "status" + required: false + schema: + type: "string" + - description: "Maximum items to return, from 1 to 100; default 20." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + - description: "Opaque nextCursor returned by the previous page; omit for the\ + \ first page." + in: "query" + name: "cursor" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationTaskListV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "List my tasks" + /qqq/v1/qbit/collaboration/records/{table}/{pk}/assignment: + put: + description: "Set record owner and assignee. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "path" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "path" + name: "pk" + required: true + schema: + type: "string" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationAssignmentRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationAssignmentV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Set record owner and assignee" + /qqq/v1/qbit/collaboration/my-work: + get: + description: "Get my work summary. Uses the signed-in caller's permissions and\ + \ record visibility." + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMyWorkV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Get my work summary" diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java index 3edb257..28f6cb4 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java @@ -59,7 +59,7 @@ class CollaborationApiContractTest assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration")); assertThat(model.getPaths().keySet()).allMatch(pathName -> pathName.startsWith("/qqq/v1/qbit/collaboration/")); assertThat(generated).contains("multipart/form-data", "format: \"binary\"", "clients follow url", "nextCursor"); - assertThat(extension.getSpecs()).hasSize(31); + assertThat(extension.getSpecs()).hasSize(42); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java index bf37263..81b83f1 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java @@ -111,6 +111,61 @@ class CollaborationApiHttpTest extends PersonalServiceFixture MemoryStorage.objects.clear(); } + /******************************************************************************* + ** Sharing validates recipients and access requests never reveal parent existence. + *******************************************************************************/ + @Test + void sharingAndAccessRequests() throws Exception + { + assertThat(ok("GET", "/records/document/001/link", null).get("label").asText()).isEqualTo("First document"); + var preview = ok("POST", "/records/document/1/shares/preview", "{\"toUserIds\":[\"alice\",\"unknown\"]}"); + assertThat(preview.at("/recipients/0/canRead").asBoolean()).isTrue(); + assertThat(preview.at("/recipients/1/canRead").isNull()).isTrue(); + assertThat(ok("POST", "/records/document/1/shares", "{\"toUserIds\":[\"alice\"],\"message\":\"Review\"}").get("items")).hasSize(1); + assertError(400, "POST", "/records/document/1/shares", "{\"toUserIds\":[]}", "alice"); + assertError(403, "POST", "/records/document/1/shares/preview", "{\"toUserIds\":[\"alice\"]}", "reader"); + assertError(404, "GET", "/records/document/3/link", null, "alice"); + var request = ok("POST", "/access-requests", "{\"tableName\":\"document\",\"recordId\":\"999\",\"message\":\"Please\"}"); + assertThat(request.get("status").asText()).isEqualTo("OPEN"); + assertThat(ok("GET", "/access-requests/mine", null).get("items")).hasSize(1); + assertThat(json(request("GET", "/access-requests/mine", null, "reader"), 200).get("items")).isEmpty(); + assertError(400, "POST", "/access-requests", "{\"tableName\":\"document\"}", "alice"); + } + + /******************************************************************************* + ** Tasks support partial edits, status transitions, caller lists and assignment. + *******************************************************************************/ + @Test + void tasksAssignmentsAndMyWork() throws Exception + { + var task = ok("POST", "/records/document/1/tasks", "{\"title\":\"Review\",\"dueAt\":\"2026-12-01T00:00:00Z\"}"); + String path = "/tasks/" + task.get("id").asInt(); + assertThat(ok("GET", "/records/document/1/tasks", null).get("items")).hasSize(1); + assertThat(ok("GET", "/tasks/mine", null).get("items")).hasSize(1); + assertThat(ok("PATCH", path, "{\"notes\":\"Keep title\"}").get("title").asText()).isEqualTo("Review"); + assertThat(ok("PATCH", path, "{\"status\":\"DONE\"}").get("status").asText()).isEqualTo("DONE"); + assertThat(ok("GET", "/tasks/mine?status=DONE", null).get("items")).hasSize(1); + assertThat(ok("GET", "/records/document/1/tasks", null).get("items")).isEmpty(); + assertThat(ok("GET", "/records/document/1/tasks?includeDone=true", null).get("items")).hasSize(1); + ok("PATCH", path, "{\"status\":\"OPEN\"}"); + ok("PATCH", path, "{\"status\":\"CANCELLED\"}"); + ok("PATCH", path, "{\"status\":\"OPEN\",\"dueAt\":null}"); + assertThat(ok("PUT", "/records/document/1/assignment", "{\"ownerUserId\":\"alice\",\"assigneeUserId\":\"alice\"}").get("ownerUserId").asText()).isEqualTo("alice"); + var work = ok("GET", "/my-work", null); + assertThat(work.get("openTaskCount").asInt()).isEqualTo(1); + assertThat(work.at("/assignedRecordsByTable/document").asInt()).isEqualTo(1); + assertThat(work.get("tasks")).hasSize(1); + assertError(400, "PATCH", path, "{\"status\":\"INVALID\",\"title\":\"Not saved\"}", "alice"); + assertError(400, "PATCH", path, "{\"dueAt\":\"invalid\"}", "alice"); + assertError(403, "PATCH", path, "{\"title\":\"Denied\"}", "reader"); + assertError(404, "PATCH", path, "{}", "outsider"); + assertError(404, "POST", "/records/document/3/tasks", "{}", "alice"); + assertError(403, "PUT", "/records/document/1/assignment", "{}", "reader"); + assertError(400, "GET", "/tasks/mine?status=UNKNOWN", null, "alice"); + assertError(400, "GET", "/records/document/1/tasks?includeDone=wrong", null, "alice"); + assertError(400, "POST", "/records/document/1/tasks", "{\"title\":5}", "alice"); + } + /******************************************************************************* ** Every personal operation and offset cursor runs through the HTTP boundary. *******************************************************************************/ @@ -328,7 +383,7 @@ class CollaborationApiHttpTest extends PersonalServiceFixture assertThat(schemas.has("CollaborationCommentPageV1")).isTrue(); assertThat(schemas.at("/CollaborationFollowV1/properties/reason/enum")).hasSize(6); assertThat(description.get("paths").has("/qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments")).isTrue(); - assertThat(description.get("paths").has("/qqq/v1/qbit/collaboration/my-work")).isFalse(); + assertThat(description.get("paths").has("/qqq/v1/qbit/collaboration/my-work")).isTrue(); JsonNode version = json(client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/qqq/v1/version")).GET().build(), HttpResponse.BodyHandlers.ofString()), 200); assertThat(version.at("/extensions/0/name").asText()).isEqualTo("collaboration"); diff --git a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java index 1b33dee..f8e3960 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java @@ -44,8 +44,8 @@ import com.kingsrook.qbits.collaboration.notify.NoOpNotifier; import com.kingsrook.qbits.collaboration.notify.RecordingNotifier; import com.kingsrook.qbits.collaboration.people.PeopleService; import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; -import com.kingsrook.qbits.collaboration.security.OtherUserAccess; -import com.kingsrook.qbits.collaboration.security.UserSessionSupplier; +import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker; +import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; @@ -101,7 +101,7 @@ public class ConversationServicesTest extends PersonalServiceFixture sessions.put("bob", session("bob", "a")); sessions.put("eve", session("eve", "b")); sessions.put("alice", session("alice", "a")); - config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionSupplier(new QCodeReference(TestSessions.class)); + config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionProvider(new QCodeReference(TestSessions.class)); QContext.getQSession().getUser().setFullName("Alice Example"); new QInstanceValidator().validate(instance); } @@ -121,10 +121,10 @@ public class ConversationServicesTest extends PersonalServiceFixture /******************************************************************************* ** The provider returns explicit test identities with real record locks. *******************************************************************************/ - public static class TestSessions implements UserSessionSupplier + public static class TestSessions implements UserSessionProviderInterface { @Override - public Optional sessionFor(String userId) + public Optional sessionForUser(String userId) { return (Optional.ofNullable(sessions.get(userId))); } @@ -166,11 +166,11 @@ public class ConversationServicesTest extends PersonalServiceFixture }); comments.edit(entry.id(), "Again @[Bob](user:bob)"); assertThat(recorder.notifications()).hasSize(1); - config.withUserSessionSupplier(null); + config.withUserSessionProvider(null); CommentEntry unknown = comments.post("document", 2, "@[Bob](user:bob)", null, null); assertThat(unknown.mentions()).containsExactly(new MentionEntry("bob", false)); assertThat(recorder.notifications()).hasSize(1); - assertThat(new OtherUserAccess().canRead("bob", "document", 1)).isEmpty(); + assertThat(new CollaborationRecordAccessChecker().canRead("bob", "document", 1)).isEmpty(); } /******************************************************************************* @@ -197,7 +197,7 @@ public class ConversationServicesTest extends PersonalServiceFixture assertThat(recorder.notifications()).hasSize(1); QContext.clearTransaction(); sessions.put("bob", session("wrong-user", "a")); - assertThat(new OtherUserAccess().canRead("bob", "document", 1)).isEmpty(); + assertThat(new CollaborationRecordAccessChecker().canRead("bob", "document", 1)).isEmpty(); new NoOpNotifier().notify(recorder.notifications().getFirst()); } @@ -493,7 +493,7 @@ public class ConversationServicesTest extends PersonalServiceFixture new CollaborationQBitProducer().withQBitConfig(configured).produce(installed); assertThat(installed.getProcess("collaborationFollow_document").getLabel()).isEqualTo("Follow"); assertThat(installed.getProcess("collaborationUnfollow_document").getLabel()).isEqualTo("Unfollow"); - assertThat(installed.getRecordChangeListeners()).hasSize(2); + assertThat(installed.getRecordChangeListeners()).hasSize(3); assertThat(installed.getProcess("collaborationAddTags_document").getLabel()).isEqualTo("Add tags"); assertThat(installed.getProcess("collaborationRemoveTags_document").getLabel()).isEqualTo("Remove tags"); assertThat(installed.getProcess(SendFollowDigestsProcess.NAME).getSchedule().getRepeatSeconds()).isEqualTo(60); diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CoreContractsTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CoreContractsTest.java new file mode 100644 index 0000000..d4573a0 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/CoreContractsTest.java @@ -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; + +import java.util.List; +import java.util.ArrayList; +import java.util.concurrent.CopyOnWriteArrayList; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +import com.kingsrook.qbits.collaboration.tags.TagsService; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver; +import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeatureSummaryHelper; +import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +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.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +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.RecordFeaturesMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.realtime.RealtimeBus; +import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Core contract integration, using real action and metadata resolution paths. + *******************************************************************************/ +class CoreContractsTest extends PersonalServiceFixture +{ + /******************************************************************************* + ** Instance defaults and table-specific declarations follow core precedence. + *******************************************************************************/ + @Test + void defaultsAndTableDeclarations() throws Exception + { + var configured = new CollaborationQBitConfig().withTableFeatures("document", RecordFeature.TAGS); + instance.getSupplementalMetaData().remove(RecordFeaturesMetaData.NAME); + configured.applyFeatures(instance); + assertThat(RecordFeaturesMetaData.of(instance).getEnabled()).containsEntry(RecordFeature.FAVORITES, true) + .containsEntry(RecordFeature.RECENTS, true).containsEntry(RecordFeature.TIMELINE, true).containsEntry(RecordFeature.COMMENTS, false); + RuntimeMetaDataOverlay.refresh(instance); + assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("document")).enabledFeatures()).containsExactly(RecordFeature.TAGS); + } + + /******************************************************************************* + ** Committed policy refreshes the overlay while rollback publishes no generation. + *******************************************************************************/ + @Test + void settingsRefreshAfterCommitAndDeleteRestoresDefaults() throws Exception + { + admin(); + RuntimeMetaDataOverlay.refresh(instance); + long generation = RuntimeMetaDataOverlay.getGeneration(instance); + assertThat(instance.getTable("document").getVirtualFields()).containsKey("collaborationTags"); + assertThat(instance.getTable("document").getVirtualField("collaborationTags").getIsQuerySelectable()).isFalse(); + QBackendTransaction tx = new QBackendTransaction(); + var row = new InsertAction().execute(new InsertInput(CollaborationTables.FEATURE_SETTING).withTransaction(tx).withRecords(List.of( + new QRecord().withValue("scopeType", "TABLE").withValue("scopeName", "document").withValue("feature", "TAGS") + .withValue("enabled", false).withValue("settings", "{\"mode\":\"APPROVED_ONLY\"}")))).getRecords().getFirst(); + assertThat(row.getErrors()).isNullOrEmpty(); + assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isEqualTo(generation); + tx.commit(); + assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isGreaterThan(generation); + assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("document")).isEnabled(RecordFeature.TAGS)).isFalse(); + assertThat(instance.getTable("document").getVirtualFields()).doesNotContainKey("collaborationTags"); + new UpdateAction().execute(new UpdateInput(CollaborationTables.FEATURE_SETTING).withRecords(List.of(new QRecord().withValue("id", row.getValue("id")).withValue("enabled", true)))); + assertThat(instance.getTable("document").getVirtualFields()).containsKey("collaborationTags"); + new DeleteAction().execute(new DeleteInput(CollaborationTables.FEATURE_SETTING).withPrimaryKeys(List.of(row.getValue("id")))); + assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("document")).isEnabled(RecordFeature.TAGS)).isTrue(); + generation = RuntimeMetaDataOverlay.getGeneration(instance); + tx = new QBackendTransaction(); + new FeatureSettingsCustomizer().postInsert(new InsertInput(CollaborationTables.FEATURE_SETTING).withTransaction(tx), List.of()); + tx.rollback(); + assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isEqualTo(generation); + } + + /******************************************************************************* + ** Implied grants require WRITE locks; explicit feature grants retain READ checks. + *******************************************************************************/ + @Test + void impliedFeatureGrantChecksWriteLocks() throws Exception + { + instance.addSecurityKeyType(new QSecurityKeyType().withName("editor")); + instance.getTable("document").withRecordSecurityLock(new RecordSecurityLock().withFieldName("tenant") + .withSecurityKeyType("editor").withLockScope(RecordSecurityLock.LockScope.WRITE)); + QContext.getQSession().withSecurityKeyValue("editor", "b"); + assertThat(ParentRecordAccess.requireVisibleParent("document", 1)).isNotNull(); + assertThatThrownBy(() -> ParentRecordAccess.requireFeature("document", 1, "comment")).isInstanceOf(QPermissionDeniedException.class); + QContext.getQSession().withPermissions("document.comment"); + ParentRecordAccess.requireFeature("document", 1, "comment"); + } + + /******************************************************************************* + ** Configured mirrors are generated, populated and updated by core. + *******************************************************************************/ + @Test + void mirrorsAndSchemaUseConfiguredKeys() throws Exception + { + config.withMirroredSecurityKeyTypes(List.of("tenant")); + var security = com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceSecurityConfig.of(instance); + security.withMirroredSecurityKeyTypes(List.of("tenant")); + for(var table : instance.getTables().values()) + { + CoreCollaboration.reference(table, config); + } + var comment = new com.kingsrook.qbits.collaboration.comments.CommentsService().post("document", 1, "Hello", null, null); + var row = CollaborationRecords.query(CollaborationTables.COMMENT, new QQueryFilter()).getFirst(); + assertThat(row.getValueString("lockKey_tenant")).isEqualTo("a"); + assertThat(CollaborationSchema.postgresScript(config)).contains("lock_key_tenant", "ADD COLUMN IF NOT EXISTS lock_key_tenant"); + QContext.getQSession().withSecurityKeyValue("allTenants", true); + new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", 1).withValue("tenant", "b")))); + assertThat(CollaborationRecords.cleanupQuery(CollaborationTables.COMMENT, new QQueryFilter()).getFirst().getValueString("lockKey_tenant")).isEqualTo("b"); + login("alice"); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_TAG)).getRecords()).isEmpty(); + assertThat(comment.id()).isPositive(); + } + + /******************************************************************************* + ** Core query expansion and summaries expose the same persisted collaboration state. + *******************************************************************************/ + @Test + void virtualTagCriteriaAndSummary() throws Exception + { + RuntimeMetaDataOverlay.refresh(instance); + var tag = new TagsService().create("document", "Urgent", null); + new TagsService().apply("document", 1, List.of(tag.id()), List.of()); + var matches = new QueryAction().execute(new QueryInput("document").withFilter(new QQueryFilter() + .withCriteria("collaborationTags", QCriteriaOperator.EQUALS, tag.id()))).getRecords(); + assertThat(matches).extracting(row -> row.getValueInteger("id")).containsExactly(1); + new com.kingsrook.qbits.collaboration.comments.CommentsService().post("document", 1, "Hello", null, null); + new com.kingsrook.qbits.collaboration.tasks.TasksService().create("document", 1, "Review", null, null, null, null); + new com.kingsrook.qbits.collaboration.assignment.AssignmentService().assign("document", 1, "alice", "alice"); + var summary = RecordFeatureSummaryHelper.summarize(instance.getTable("document"), ParentRecordAccess.requireVisibleParent("document", 1)); + assertThat(summary.getCounts()).containsEntry("tags", 1).containsEntry("openComments", 1).containsEntry("openTasks", 1).containsEntry("attachments", 0).containsEntry("followers", 1); + assertThat(summary.getIsFollowing()).isTrue(); + assertThat(summary.getIsFavorite()).isFalse(); + assertThat(summary.getOwner()).isEqualTo("alice"); + assertThat(summary.getAssignee()).isEqualTo("alice"); + } + + /******************************************************************************* + ** Realtime hints contain identifiers and obey commit/rollback boundaries. + *******************************************************************************/ + @Test + void realtimeCommitsAndRollbackDiscardsHints() throws Exception + { + List messages = new CopyOnWriteArrayList<>(); + try(var subscription = RealtimeBus.get(instance).subscribe(messages::add)) + { + var tx = new QBackendTransaction(); + QContext.setTransaction(tx); + var comment = new com.kingsrook.qbits.collaboration.comments.CommentsService().post("document", 1, "Private body", null, null); + assertThat(messages).noneMatch(message -> message.getEvent().equals("comment.added")); + tx.commit(); + QContext.clearTransaction(); + assertThat(messages).anyMatch(message -> message.getEvent().equals("comment.added") && message.getChannel().equals("record:document:1")); + assertThat(messages).allSatisfy(message -> assertThat(message.getData().toString()).doesNotContain("Private body")); + messages.clear(); + tx = new QBackendTransaction(); + QContext.setTransaction(tx); + new com.kingsrook.qbits.collaboration.comments.CommentsService().edit(comment.id(), "Changed"); + tx.rollback(); + QContext.clearTransaction(); + assertThat(messages).noneMatch(message -> message.getEvent().equals("comment.updated")); + new com.kingsrook.qbits.collaboration.comments.CommentsService().edit(comment.id(), "Committed"); + assertThat(messages).anyMatch(message -> message.getEvent().equals("comment.updated")); + } + RealtimeBus.close(instance); + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java b/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java index 6e56b2a..6150ce3 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java @@ -68,7 +68,7 @@ public abstract class PersonalServiceFixture instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); instance.setEnforcePermissionsInCoreActions(true); - config = new CollaborationQBitConfig().withBackendName("memory"); + config = new CollaborationQBitConfig().withBackendName("memory").withDefaultFeatures(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature.values()); new CollaborationQBitProducer().withQBitConfig(config).produce(instance); instance.addSecurityKeyType(new QSecurityKeyType().withName("tenant").withAllAccessKeyName("allTenants")); instance.addTable(new QTableMetaData().withName("document").withLabel("Documents").withBackendName("memory").withPrimaryKeyField("id") @@ -79,6 +79,7 @@ public abstract class PersonalServiceFixture .withRecordLabelFormatAndFields("%s", "title") .withRecordSecurityLock(new RecordSecurityLock().withFieldName("tenant").withSecurityKeyType("tenant") .withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE))); + new com.kingsrook.qqq.backend.core.instances.QInstanceEnricher(instance).enrich(); login("alice"); QContext.getQSession().withSecurityKeyValue("allTenants", true); InsertInput insert = new InsertInput("document").withRecords(List.of( diff --git a/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java b/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java index dd02e21..96af103 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java @@ -40,7 +40,7 @@ import com.kingsrook.qbits.collaboration.mywork.MyWorkService; import com.kingsrook.qbits.collaboration.notify.CollaborationNotification; import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier; import com.kingsrook.qbits.collaboration.notify.RecordingNotifier; -import com.kingsrook.qbits.collaboration.security.UserSessionSupplier; +import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface; import com.kingsrook.qbits.collaboration.sharing.AccessRequestService; import com.kingsrook.qbits.collaboration.sharing.PermissionHolderDirectory; import com.kingsrook.qbits.collaboration.sharing.SharingService; @@ -102,7 +102,7 @@ public class SharingWorkTest extends PersonalServiceFixture sessions.put("alice", session("alice", "a")); sessions.put("bob", session("bob", "a")); sessions.put("eve", session("eve", "b")); - config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionSupplier(new QCodeReference(TestSessions.class)); + config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionProvider(new QCodeReference(TestSessions.class)); QContext.getQSession().getUser().setFullName("Alice"); CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "eve").withValue("displayName", "Sam")); WorkProcesses.assignments("document").forEach(instance::addProcess); @@ -127,13 +127,13 @@ public class SharingWorkTest extends PersonalServiceFixture /******************************************************************************* ** Test Sessions. *******************************************************************************/ - public static class TestSessions implements UserSessionSupplier + public static class TestSessions implements UserSessionProviderInterface { /******************************************************************************* ** Use the configured test boundary. *******************************************************************************/ @Override - public Optional sessionFor(String user) + public Optional sessionForUser(String user) { return (Optional.ofNullable(sessions.get(user))); } @@ -310,7 +310,7 @@ public class SharingWorkTest extends PersonalServiceFixture QRecord soon = tasks.create("document", 1, "Soon", null, null, NOW.plusSeconds(3600), null); assertThat(soon.getValueInstant("remindAt")).isEqualTo(soon.getValueInstant("dueAt")); assertThat(soon.getValueString("assigneeUserId")).isEqualTo("alice"); - config.withUserSessionSupplier(null); + config.withUserSessionProvider(null); assertThat(tasks.create("document", 1, "Self", null, null, null, null).getValueInstant("remindAt")).isNull(); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java index 1209057..c804a8a 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java @@ -30,7 +30,7 @@ import com.kingsrook.qbits.collaboration.app.CollaborationHome; import com.kingsrook.qbits.collaboration.attachments.AttachmentCleanup; import com.kingsrook.qbits.collaboration.model.CollaborationTables; import com.kingsrook.qbits.collaboration.tags.TagEntry; -import com.kingsrook.qbits.collaboration.tags.TagFilterTranslator; + import com.kingsrook.qbits.collaboration.tags.TagProcesses; import com.kingsrook.qbits.collaboration.tags.TagsService; import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; @@ -72,7 +72,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; public class TagsServiceTest extends PersonalServiceFixture { private final TagsService tags = new TagsService(); - private final TagFilterTranslator filters = new TagFilterTranslator(); + /******************************************************************************* ** Natural keys are locale-independent and validate after collapsing whitespace. @@ -257,23 +257,23 @@ public class TagsServiceTest extends PersonalServiceFixture TagEntry b = tags.create("document", "B", null); tags.apply("document", 1, List.of(a.id(), b.id()), List.of()); tags.apply("document", 2, List.of(a.id()), List.of()); - assertThat(filters.recordIds("document", "IN", List.of("A"))).containsExactly("1", "2"); - assertThat(filters.recordIds("document", "IN", List.of(b.id()))).containsExactly("1"); - assertThat(filters.recordIds("document", "CONTAINS_ALL", List.of(a.id(), b.id(), a.id()))).containsExactly("1"); - assertThat(filters.recordIds("document", "CONTAINS_ALL", List.of(a.id(), "unknown"))).isEmpty(); - assertThat(filters.recordIds("document", "NOT_IN", List.of(b.id()))).containsExactly("2"); - assertThat(filters.recordIds("document", "IS_NOT_BLANK", null)).containsExactly("1", "2"); - assertThat(filters.recordIds("document", "IS_BLANK", null)).isEmpty(); + assertThat(recordIds("document", "IN", List.of("A"))).containsExactly("1", "2"); + assertThat(recordIds("document", "IN", List.of(b.id()))).containsExactly("1"); + assertThat(recordIds("document", "CONTAINS_ALL", List.of(a.id(), b.id(), a.id()))).containsExactly("1"); + assertThat(recordIds("document", "CONTAINS_ALL", List.of(a.id(), "unknown"))).isEmpty(); + assertThat(recordIds("document", "NOT_IN", List.of(b.id()))).containsExactly("2"); + assertThat(recordIds("document", "IS_NOT_BLANK", null)).containsExactly("1", "2"); + assertThat(recordIds("document", "IS_BLANK", null)).isEmpty(); tags.apply("document", 2, List.of(), List.of(a.id())); - assertThat(filters.recordIds("document", "IS_BLANK", List.of())).containsExactly("2"); - assertThat(filters.recordIds("document", "IN", List.of())).isEmpty(); - assertThat(filters.recordIds("document", "NOT_IN", List.of("unknown"))).containsExactly("1", "2"); - assertThat(filters.recordIds("document", "CONTAINS_ALL", List.of())).containsExactly("1", "2"); - assertThatThrownBy(() -> filters.recordIds("document", "LIKE", List.of())).hasMessageContaining("Unsupported"); + assertThat(recordIds("document", "IS_BLANK", List.of())).containsExactly("2"); + assertThat(recordIds("document", "IN", List.of())).isEmpty(); + assertThat(recordIds("document", "NOT_IN", List.of("unknown"))).containsExactly("1", "2"); + assertThat(recordIds("document", "CONTAINS_ALL", List.of())).containsExactly("1", "2"); + assertThatThrownBy(() -> recordIds("document", "LIKE", List.of())).hasMessageContaining("Unsupported"); } /******************************************************************************* - ** Exactly 10,000 is allowed; the next matching id fails instead of truncating. + ** The core bound applies to subquery rows, not the number of matching parents. *******************************************************************************/ @Test void translatorCap() throws Exception @@ -284,9 +284,9 @@ public class TagsServiceTest extends PersonalServiceFixture parents.add(new QRecord().withValue("id", id).withValue("tenant", "a")); } new InsertAction().execute(new InsertInput("document").withRecords(parents)); - assertThat(filters.recordIds("document", "IS_BLANK", null)).hasSize(10000); + assertThat(recordIds("document", "IS_BLANK", null)).hasSize(10000); new InsertAction().execute(new InsertInput("document").withRecords(List.of(new QRecord().withValue("id", 10002).withValue("tenant", "a")))); - assertThatThrownBy(() -> filters.recordIds("document", "IS_BLANK", null)).hasMessageContaining("10,000"); + assertThat(recordIds("document", "IS_BLANK", null)).hasSize(10001); } /******************************************************************************* @@ -445,4 +445,24 @@ public class TagsServiceTest extends PersonalServiceFixture { return ((List) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY)); } + /******************************************************************************* + ** Execute expanded criteria through the real parent query action. + *******************************************************************************/ + private List recordIds(String table, String operator, java.util.Collection values) throws Exception + { + var metadata = instance.getTable(table); + com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator op; + try + { + op = com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.valueOf(operator); + } + catch(IllegalArgumentException e) + { + throw new IllegalArgumentException("Unsupported operator"); + } + var filter = new com.kingsrook.qbits.collaboration.tags.TagCriteriaExpander().expand(metadata, + new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria("collaborationTags", op, + new java.util.ArrayList(values == null ? List.of() : values)), null); + return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords().stream().map(row -> row.getValueString("id")).toList()); + } } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java index a10d60a..e7f8b2a 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java @@ -202,6 +202,7 @@ public class AttachmentsServiceTest extends PersonalServiceFixture QContext.getQSession().getPermissions().remove("document.write"); assertThatThrownBy(() -> service.delete(List.of(bob.id()))).isInstanceOf(QPermissionDeniedException.class); admin(); + QContext.getQSession().withPermissions("document.attach"); QContext.getQSession().getPermissions().remove("document.write"); assertThat(service.rename(alice.id(), "renamed.txt", "new description").fileName()).isEqualTo("renamed.txt"); assertThat(service.describe(alice.id(), "").description()).isEmpty(); -- 2.54.0 From 08498dd1db6625858d788393f24ba59d857c0de6 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 21:09:49 -0500 Subject: [PATCH 03/12] 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 --- CHANGELOG.md | 6 +- README.md | 104 ++++- docs/DESIGN.md | 36 ++ pom.xml | 4 + .../collaboration/CollaborationCleanup.java | 2 +- .../CollaborationQBitConfig.java | 26 ++ .../CollaborationQBitProducer.java | 2 + .../collaboration/CollaborationSchema.java | 12 + .../CollaborationSummaryProvider.java | 12 + .../collaboration/CoreCollaboration.java | 2 + .../FeatureSettingsProvider.java | 53 +++ .../collaboration/api/CollaborationApi.java | 3 +- .../api/CollaborationPresenceSchemas.java | 125 ++++++ .../collaboration/api/DeletePresenceSpec.java | 82 ++++ .../collaboration/api/GetPresenceSpec.java | 73 ++++ .../collaboration/api/PostPresenceSpec.java | 82 ++++ .../collaboration/app/CollaborationHome.java | 32 +- .../customfields/BuildFieldIndexes.java | 202 ++++++++++ .../customfields/CustomFieldStorage.java | 140 +++++++ .../CustomFieldTablesProvider.java | 68 ++++ .../customfields/CustomFieldsProvider.java | 163 ++++++++ .../customfields/FieldCriteriaExpander.java | 110 ++++++ .../FieldDefinitionCustomizer.java | 99 +++++ .../customfields/FieldDefinitions.java | 280 ++++++++++++++ .../customfields/FieldProcesses.java | 187 +++++++++ .../customfields/FieldSecurity.java | 70 ++++ .../model/CollaborationTables.java | 69 ++++ .../model/ConversationChildren.java | 6 + .../presence/PresenceRegistry.java | 366 ++++++++++++++++++ .../presence/PresenceService.java | 151 ++++++++ .../security/CollaborationRowSecurity.java | 2 +- .../tags/TagsMetaDataProvider.java | 56 +-- .../openapi/qqq-v1/collaboration.yaml | 142 ++++++- .../CollaborationApiContractTest.java | 2 +- .../CollaborationApiHttpTest.java | 18 + .../collaboration/CollaborationTest.java | 6 +- .../collaboration/CustomFieldSqlTest.java | 223 +++++++++++ .../qbits/collaboration/CustomFieldsTest.java | 273 +++++++++++++ .../collaboration/PersonalServiceFixture.java | 2 + .../qbits/collaboration/PresenceTest.java | 187 +++++++++ 40 files changed, 3412 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationPresenceSchemas.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/DeletePresenceSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/GetPresenceSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/api/PostPresenceSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/customfields/BuildFieldIndexes.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldStorage.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldTablesProvider.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldsProvider.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldCriteriaExpander.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldDefinitionCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldDefinitions.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldProcesses.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldSecurity.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/presence/PresenceRegistry.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/presence/PresenceService.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/CustomFieldSqlTest.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/PresenceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f041e0..003dc63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Added -- Collaboration UI API extension `1.0.0`: 42 typed operations for favorites, recents, attachments, tags, comments, mentions, follow, timeline, people, sharing, access requests, tasks, assignment and My work under `/qqq/v1/qbit/collaboration`, enabled by default and disabled with `withUiApi(false)`. +- 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. diff --git a/README.md b/README.md index fd6fa00..e0368fa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # QBit Collaboration -Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and core-integrated tag filtering/search. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g remain planned. Runtime overrides and TAGS mode settings resolve through core. +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). @@ -506,3 +506,105 @@ New routes, all under `/qqq/v1/qbit/collaboration`: 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. The optional followers provider is skipped because `RecordFollowersProviderInterface` is absent from the installed jar. Selectable Tags values remain unavailable because the overlay has no output hook. + + +## 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_` 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 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. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b228f4e..f3ec026 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -126,3 +126,39 @@ Feature writes refresh the overlay after commit. Initial loading follows core's `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. diff --git a/pom.xml b/pom.xml index 8962254..ebff939 100644 --- a/pom.xml +++ b/pom.xml @@ -67,6 +67,10 @@ + + com.kingsrook.qqq + qqq-backend-module-rdbms + com.kingsrook.qbits qbit-object-storage diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationCleanup.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationCleanup.java index b7e8049..5d2e155 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationCleanup.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationCleanup.java @@ -42,7 +42,7 @@ public class CollaborationCleanup implements RecordReferenceCleanupInterface public void onParentsDeleted(String tableName, List primaryKeys) throws QException { new AttachmentCleanup().onParentsDeleted(tableName, primaryKeys); - for(String table : List.of(CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION, CollaborationTables.COMMENT, + for(String table : List.of(CollaborationTables.FIELD_VALUE, CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION, CollaborationTables.COMMENT, CollaborationTables.FOLLOW_DIGEST, CollaborationTables.FOLLOW, CollaborationTables.ACTIVITY)) { while(true) diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java index c97d835..c060c86 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java @@ -57,6 +57,7 @@ public class CollaborationQBitConfig implements QBitConfig private List assignActionTables = List.of(); private String backendName; private String schedulerName; + private Boolean managedIndexes = true; private String appName = "collaboration"; private String parentAppName; private Boolean adminApp = true; @@ -629,4 +630,29 @@ public class CollaborationQBitConfig implements QBitConfig 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; + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java index 9ea096c..2bee938 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java @@ -128,6 +128,8 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer 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"); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSummaryProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSummaryProvider.java index 38cae58..d168c3c 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSummaryProvider.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSummaryProvider.java @@ -47,6 +47,18 @@ public class CollaborationSummaryProvider implements RecordFeatureSummaryProvide 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 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() diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java b/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java index c543ee5..39b6f98 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CoreCollaboration.java @@ -41,6 +41,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; public final class CoreCollaboration { private static final Map 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), @@ -69,6 +70,7 @@ public final class CoreCollaboration var features = RecordFeaturesMetaData.ofOrWithNew(instance); 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)); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsProvider.java index e31fe58..a03899b 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsProvider.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/FeatureSettingsProvider.java @@ -35,6 +35,8 @@ 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. @@ -72,4 +74,55 @@ public class FeatureSettingsProvider implements RecordFeatureOverrideProviderInt } 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 overrides) + { + List levels = new ArrayList<>(); + List 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 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); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java index d1bf034..40da4e2 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationApi.java @@ -53,7 +53,8 @@ public final class CollaborationApi public static MiddlewareSpecExtension extension() { return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0") - .withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline and people") + .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) diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationPresenceSchemas.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationPresenceSchemas.java new file mode 100644 index 0000000..2e88a3b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationPresenceSchemas.java @@ -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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/DeletePresenceSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/DeletePresenceSpec.java new file mode 100644 index 0000000..66ab0c7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/DeletePresenceSpec.java @@ -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 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/GetPresenceSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/GetPresenceSpec.java new file mode 100644 index 0000000..c83f55f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/GetPresenceSpec.java @@ -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 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))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/PostPresenceSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/PostPresenceSpec.java new file mode 100644 index 0000000..39fbc87 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/PostPresenceSpec.java @@ -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 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()))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java index d8f6f9d..4e376b7 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java @@ -83,7 +83,7 @@ public final class CollaborationHome .withPermissionRules(CollaborationPermissions.permissionRules()) .withWidgets(List.of(HOME_WIDGET)) .withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum")) - .withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, 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); } @@ -142,6 +142,7 @@ public final class CollaborationHome personalSummary(html, CollaborationTables.RECENT, "Recently viewed"); attachmentSummary(html); tagSummary(html); + fieldSummary(html); html.append("

    Open access requests

    ").append(count(CollaborationTables.ACCESS_REQUEST, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("

    "); html.append("

    Open tasks

    ").append(count(CollaborationTables.TASK, @@ -241,6 +242,35 @@ public final class CollaborationHome + /******************************************************************************* + ** Real definition status and index totals, plus ephemeral admin-only counts. + *******************************************************************************/ + private static void fieldSummary(StringBuilder html) throws QException + { + html.append("

    Custom fields

      "); + for(String status : List.of("DRAFT", "ACTIVE", "ARCHIVED")) + { + html.append("
    • ").append(status).append(": ").append(count(CollaborationTables.FIELD_DEFINITION, + new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, status))).append("
    • "); + } + for(String state : List.of("NONE", "BUILDING", "READY", "FAILED")) + { + html.append("
    • Index ").append(state).append(": ").append(count(CollaborationTables.FIELD_DEFINITION, + new QQueryFilter().withCriteria("indexState", QCriteriaOperator.EQUALS, state))).append("
    • "); + } + html.append("

    Definitions with storage or index messages: ").append(count(CollaborationTables.FIELD_DEFINITION, + new QQueryFilter().withCriteria("statusMessage", QCriteriaOperator.IS_NOT_BLANK))).append(". See Custom Fields for details.

    "); + if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN)) + { + html.append("

    Who's here

      "); + com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, count) -> + html.append("
    • ").append(table.replace("&", "&").replace("<", "<").replace(">", ">")).append(": ").append(count).append("
    • ")); + html.append("
    "); + } + } + + + /******************************************************************************* ** Widget renderer instantiated by QQQ. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/collaboration/customfields/BuildFieldIndexes.java b/src/main/java/com/kingsrook/qbits/collaboration/customfields/BuildFieldIndexes.java new file mode 100644 index 0000000..f67451e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/customfields/BuildFieldIndexes.java @@ -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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldStorage.java b/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldStorage.java new file mode 100644 index 0000000..cfc5959 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldStorage.java @@ -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> 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("."))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldTablesProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldTablesProvider.java new file mode 100644 index 0000000..4bb441b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldTablesProvider.java @@ -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 +{ + /******************************************************************************* + ** Resolve code and persisted policy consistently with the overlay. + *******************************************************************************/ + @Override + protected List 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 makePossibleValue(QTableMetaData table) + { + return (new QPossibleValue<>(table.getName(), table.getLabel())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldsProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldsProvider.java new file mode 100644 index 0000000..f4ff9ca --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldsProvider.java @@ -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 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.addPossibleValueSource(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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldCriteriaExpander.java b/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldCriteriaExpander.java new file mode 100644 index 0000000..9d308f7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldCriteriaExpander.java @@ -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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldDefinitionCustomizer.java b/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldDefinitionCustomizer.java new file mode 100644 index 0000000..0362275 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldDefinitionCustomizer.java @@ -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 preInsert(InsertInput input, List 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 preUpdate(UpdateInput input, List records, boolean preview, Optional> 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 preDelete(DeleteInput input, List 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldDefinitions.java b/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldDefinitions.java new file mode 100644 index 0000000..f4b8cbc --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldDefinitions.java @@ -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> options(QRecord definition) throws QException + { + Map> 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)); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldProcesses.java b/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldProcesses.java new file mode 100644 index 0000000..c60f9e0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldProcesses.java @@ -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 metadata() + { + List processes = new ArrayList<>(); + for(String operation : List.of("Publish", "Archive", "Rebuild")) + { + var values = new HashMap(); + 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 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 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 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 getProcessSummary(RunBackendStepOutput output, boolean result) + { + return (lines); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldSecurity.java b/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldSecurity.java new file mode 100644 index 0000000..a6522a3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/customfields/FieldSecurity.java @@ -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.")); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java index 03adfd0..78e6f23 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java @@ -58,6 +58,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; *******************************************************************************/ public final class CollaborationTables { + public static final String FIELD_DEFINITION = "collaboration_field_definition"; + public static final String FIELD_VALUE = "collaboration_field_value"; public static final String SHARE = "collaboration_share"; public static final String ACCESS_REQUEST = "collaboration_access_request"; public static final String TASK = "collaboration_task"; @@ -97,6 +99,13 @@ public final class CollaborationTables public static List possibleValueSources() { return (List.of( + QPossibleValueSource.newForTable(FIELD_DEFINITION), QPossibleValueSource.newForTable(FIELD_VALUE), + enumSource("collaboration_field_type", "STRING", "TEXT", "INTEGER", "DECIMAL", "BOOLEAN", "DATE", "DATE_TIME", "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK"), + enumSource("collaboration_field_status", "DRAFT", "ACTIVE", "ARCHIVED"), + enumSource("collaboration_field_index_state", "NONE", "BUILDING", "READY", "FAILED"), + enumSource("collaboration_field_classification", "PUBLIC", "CONFIDENTIAL", "PII", "PHI"), + new QPossibleValueSource().withName("collaboration_custom_field_tables").withType(QPossibleValueSourceType.CUSTOM).withIdType(QFieldType.STRING) + .withCustomCodeReference(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.CustomFieldTablesProvider.class)), QPossibleValueSource.newForTable(SHARE), QPossibleValueSource.newForTable(ACCESS_REQUEST), QPossibleValueSource.newForTable(TASK), QPossibleValueSource.newForTable(RECORD_ASSIGNMENT), enumSource("collaboration_access_request_status", "OPEN", "GRANTED", "DECLINED", "WITHDRAWN"), @@ -177,6 +186,7 @@ public final class CollaborationTables tables.addAll(conversationTables(config)); tables.addAll(tagTables(config)); tables.addAll(workTables(config)); + tables.addAll(customFieldTables(config)); for(QTableMetaData table : tables) { table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates")); @@ -454,4 +464,63 @@ public final class CollaborationTables } return (tables); } + + /******************************************************************************* + ** The designer is an ordinary audited table; fallback values are children. + *******************************************************************************/ + private static List customFieldTables(CollaborationQBitConfig config) + { + QTableMetaData definitions = base(config, FIELD_DEFINITION, "Custom Fields", "input") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED) + .withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.FieldSecurity.class))) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)) + .withRecordLabelFormatAndFields("%s: %s", "tableName", "label") + .withField(string("tableName", "Table", 128).withIsRequired(true).withPossibleValueSourceName("collaboration_custom_field_tables")) + .withField(string("name", "Name", 41).withIsRequired(true)) + .withField(string("label", "Label", 255).withIsRequired(true)) + .withField(string("type", "Type", 32).withIsRequired(true).withPossibleValueSourceName("collaboration_field_type")) + .withField(new QFieldMetaData("options", QFieldType.TEXT).withLabel("Options (JSON)")) + .withField(string("linkTableName", "Link table", 128)) + .withField(new QFieldMetaData("required", QFieldType.BOOLEAN).withDefaultValue(false)) + .withField(new QFieldMetaData("defaultValue", QFieldType.TEXT)) + .withField(new QFieldMetaData("minValue", QFieldType.DECIMAL)) + .withField(new QFieldMetaData("maxValue", QFieldType.DECIMAL)) + .withField(new QFieldMetaData("maxLength", QFieldType.INTEGER)) + .withField(new QFieldMetaData("helpText", QFieldType.TEXT)) + .withField(string("sectionName", "Section", 128)) + .withField(new QFieldMetaData("sortOrder", QFieldType.INTEGER).withDefaultValue(0)) + .withField(new QFieldMetaData("filterable", QFieldType.BOOLEAN).withDefaultValue(false)) + .withField(new QFieldMetaData("searchable", QFieldType.BOOLEAN).withDefaultValue(false)) + .withField(string("classification", "Classification", 32).withPossibleValueSourceName("collaboration_field_classification")) + .withField(string("status", "Status", 16).withDefaultValue("DRAFT").withIsRequired(true).withIsEditable(false).withPossibleValueSourceName("collaboration_field_status")) + .withField(string("indexState", "Index state", 16).withDefaultValue("NONE").withIsEditable(false).withPossibleValueSourceName("collaboration_field_index_state")) + .withField(string("storage", "Storage", 16).withIsEditable(false)) + .withField(new QFieldMetaData("statusMessage", QFieldType.TEXT).withIsEditable(false)) + .withField(new QFieldMetaData("indexDdl", QFieldType.TEXT).withLabel("Index DDL").withIsEditable(false)) + .withField(string("createdByUserId", "Created by", 255).withIsEditable(false)) + .withUniqueKey(new UniqueKey("tableName", "name")); + QCodeReference customizer = new QCodeReference(com.kingsrook.qbits.collaboration.customfields.FieldDefinitionCustomizer.class); + definitions.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, customizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, customizer) + .withCustomizer(TableCustomizers.PRE_DELETE_RECORD, customizer) + .withCustomizer(TableCustomizers.POST_INSERT_RECORD, customizer).withCustomizer(TableCustomizers.POST_UPDATE_RECORD, customizer) + .withCustomizer(TableCustomizers.POST_DELETE_RECORD, customizer) + .withSection(new QFieldSection("definition", new QIcon("input"), Tier.T1, + List.of("id", "tableName", "name", "label", "type", "status", "classification")).withLabel("Definition")) + .withSection(new QFieldSection("settings", new QIcon("settings"), Tier.T2, + List.of("options", "linkTableName", "required", "defaultValue", "minValue", "maxValue", "maxLength", "helpText", "sectionName", "sortOrder", "filterable", "searchable")).withLabel("Settings")) + .withSection(new QFieldSection("storage", new QIcon("storage"), Tier.T2, + List.of("storage", "indexState", "statusMessage", "indexDdl", "createdByUserId")).withLabel("Storage and indexes")); + QTableMetaData values = referenced(config, FIELD_VALUE, "Custom Field Values", "input") + .withField(string("fieldName", "Field name", 41).withIsRequired(true)) + .withField(new QFieldMetaData("valueText", QFieldType.TEXT)) + .withField(new QFieldMetaData("valueNumber", QFieldType.DECIMAL)) + .withField(new QFieldMetaData("valueDate", QFieldType.DATE)) + .withField(new QFieldMetaData("valueDateTime", QFieldType.DATE_TIME)) + .withField(new QFieldMetaData("valueBoolean", QFieldType.BOOLEAN)) + .withUniqueKey(new UniqueKey("tableName", "recordId", "fieldName")) + .withSection(new QFieldSection("value", new QIcon("input"), Tier.T1, + List.of("id", "fieldName", "valueText", "valueNumber", "valueDate", "valueDateTime", "valueBoolean")).withLabel("Value")); + values.getFields().values().forEach(field -> field.setIsEditable(false)); + return (List.of(definitions, values)); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java b/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java index d03dd7d..786dec9 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java @@ -48,6 +48,12 @@ public final class ConversationChildren *******************************************************************************/ public static void install(QInstance instance) { + QJoinMetaData fieldJoin = new QJoinMetaData().withLeftTable(CollaborationTables.FIELD_DEFINITION).withRightTable(CollaborationTables.FIELD_VALUE) + .withInferredName().withType(JoinType.ONE_TO_MANY).withJoinOns(List.of(new JoinOn("tableName", "tableName"), new JoinOn("name", "fieldName"))); + instance.addJoin(fieldJoin); + instance.addWidget(ChildRecordListRenderer.widgetMetaDataBuilder(fieldJoin).withLabel("Custom Field Values").withCanAddChildRecord(false).getWidgetMetaData()); + instance.getTable(CollaborationTables.FIELD_DEFINITION).withSection(new QFieldSection("values", new QIcon("list"), Tier.T2) + .withLabel("Custom Field Values").withWidgetName(fieldJoin.getName())); QJoinMetaData tagJoin = new QJoinMetaData().withLeftTable(CollaborationTables.TAG).withRightTable(CollaborationTables.RECORD_TAG) .withInferredName().withType(JoinType.ONE_TO_MANY).withJoinOns(List.of(new JoinOn("id", "tagId"))); instance.addJoin(tagJoin); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/presence/PresenceRegistry.java b/src/main/java/com/kingsrook/qbits/collaboration/presence/PresenceRegistry.java new file mode 100644 index 0000000..05f2133 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/presence/PresenceRegistry.java @@ -0,0 +1,366 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.presence; + +import java.io.Serializable; +import java.time.Clock; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.WeakHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.realtime.RealtimeBus; +import com.kingsrook.qqq.backend.core.realtime.RealtimeBusInterface; +import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage; + +/******************************************************************************* + ** Ephemeral per-pod presence replicated over a channel core refuses to clients. + ** Publish outside the monitor: the in-memory bus waits for listener threads. + *******************************************************************************/ +public final class PresenceRegistry implements AutoCloseable +{ + public static final String INTERNAL_CHANNEL = "instance:presence"; + public static final long TTL_MILLIS = 45000; + public static final int MAX_ENTRIES = 50000; + private static final QLogger LOG = QLogger.getLogger(PresenceRegistry.class); + private static final Map REGISTRIES = new WeakHashMap<>(); + private final Map entries = new HashMap<>(); + private final RealtimeBusInterface bus; + private final Clock clock; + private final int capacity; + private final AutoCloseable subscription; + private ScheduledExecutorService expiry; + + /******************************************************************************* + ** Clock and cap injection makes expiry and overload deterministic in tests. + *******************************************************************************/ + public PresenceRegistry(RealtimeBusInterface bus, Clock clock, int capacity) + { + this.bus = bus; + this.clock = clock; + this.capacity = capacity; + subscription = bus.subscribe(this::receive); + } + + /******************************************************************************* + ** Install on every pod, even before its first local heartbeat. + *******************************************************************************/ + public static synchronized PresenceRegistry get(QInstance instance) + { + return (REGISTRIES.computeIfAbsent(instance, key -> + { + var registry = new PresenceRegistry(RealtimeBus.get(instance), Clock.systemUTC(), MAX_ENTRIES); + registry.expiry = Executors.newSingleThreadScheduledExecutor(runnable -> + { + Thread thread = new Thread(runnable, "collaboration-presence-expiry"); + thread.setDaemon(true); + return (thread); + }); + registry.expiry.scheduleWithFixedDelay(() -> + { + try + { + registry.expire(); + } + catch(RuntimeException e) + { + LOG.warn("Could not expire presence", e); + } + }, 1, 1, TimeUnit.SECONDS); + return (registry); + })); + } + + /******************************************************************************* + ** Hosts stop the timer and unsubscribe before closing the instance realtime bus. + *******************************************************************************/ + public static synchronized void close(QInstance instance) + { + var registry = REGISTRIES.remove(instance); + if(registry != null) + { + registry.close(); + } + } + + /******************************************************************************* + ** Stable identity namespaces client connection ids by the authenticated user. + *******************************************************************************/ + private record Key(String user, String connection, String table, String pk) + { + } + + /******************************************************************************* + ** Each tab has its own lease; user views merge the live leases. + *******************************************************************************/ + private record Entry(String displayName, String mode, String fieldName, long at) + { + } + + /******************************************************************************* + ** Transport-independent snapshot entry, never a persisted directory row. + *******************************************************************************/ + public record Viewer(String userId, String displayName, String mode, String fieldName) implements Serializable + { + } + + /******************************************************************************* + ** Refresh locally even if transport delivery fails; replicate every heartbeat. + *******************************************************************************/ + public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName) + { + expire(); + var key = new Key(user, connection, table, pk); + var entry = new Entry(displayName, mode, fieldName, clock.millis()); + if(!apply(key, entry, false)) + { + return (false); + } + publish(key, entry, false); + return (true); + } + + /******************************************************************************* + ** Leaves use the same internal channel so every pod removes the lease. + *******************************************************************************/ + public void leave(String user, String connection, String table, String pk) + { + var key = new Key(user, connection, table, pk); + var entry = new Entry(null, null, null, clock.millis()); + apply(key, entry, true); + publish(key, entry, true); + } + + /******************************************************************************* + ** Internal payloads contain only identity and focus, never record values. + *******************************************************************************/ + private void publish(Key key, Entry entry, boolean leave) + { + Map data = new LinkedHashMap<>(); + data.put("userId", key.user()); + data.put("connectionId", key.connection()); + data.put("table", key.table()); + data.put("pk", key.pk()); + data.put("displayName", entry.displayName()); + data.put("mode", entry.mode()); + data.put("fieldName", entry.fieldName()); + data.put("at", entry.at()); + data.put("leave", leave); + bus.publish(new RealtimeMessage().withChannel(INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(data)); + } + + /******************************************************************************* + ** Client-visible change events cannot enter the replication path. + *******************************************************************************/ + private void receive(RealtimeMessage message) + { + if(!INTERNAL_CHANNEL.equals(message.getChannel()) || !"presence.heartbeat".equals(message.getEvent())) + { + return; + } + var data = message.getData(); + if(!(data.get("at") instanceof Number at) || !(data.get("userId") instanceof String user) + || !(data.get("connectionId") instanceof String connection) || !(data.get("table") instanceof String table)) + { + return; + } + expire(); + long receivedAt = Math.min(at.longValue(), clock.millis()); + if(receivedAt + TTL_MILLIS <= clock.millis()) + { + return; + } + apply(new Key(user, connection, table, (String) data.get("pk")), + new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt), Boolean.TRUE.equals(data.get("leave"))); + } + + /******************************************************************************* + ** Unchanged renewals extend TTL without generating client event traffic. + *******************************************************************************/ + private boolean apply(Key key, Entry entry, boolean leave) + { + boolean changed; + synchronized(this) + { + Entry old = entries.get(key); + if(old != null && old.at() > entry.at()) + { + return (true); + } + if(!leave && old == null && entries.size() >= capacity) + { + LOG.warn("Presence capacity reached; refusing a new target"); + return (false); + } + List before = snapshotInternal(key.table(), key.pk()); + if(leave) + { + entries.remove(key); + } + else + { + entries.put(key, entry); + } + changed = !before.equals(snapshotInternal(key.table(), key.pk())); + } + if(changed) + { + changed(key.table(), key.pk()); + } + return (true); + } + + /******************************************************************************* + ** Expiry is active even without a polling client; expired data is never returned. + *******************************************************************************/ + public void expire() + { + List removed = new ArrayList<>(); + synchronized(this) + { + entries.entrySet().removeIf(entry -> + { + if(entry.getValue().at() + TTL_MILLIS <= clock.millis()) + { + removed.add(entry.getKey()); + return (true); + } + return (false); + }); + } + removed.stream().map(key -> new Key(null, null, key.table(), key.pk())).distinct().forEach(key -> changed(key.table(), key.pk())); + } + + /******************************************************************************* + ** Table snapshots omit record keys and focused fields to avoid lock disclosures. + *******************************************************************************/ + public List snapshot(String table, String pk) + { + expire(); + synchronized(this) + { + return (snapshotInternal(table, pk)); + } + } + + /******************************************************************************* + ** Editing wins over viewing; within a mode the most recent tab supplies focus. + *******************************************************************************/ + private List snapshotInternal(String table, String pk) + { + Map users = new java.util.TreeMap<>(); + for(var row : entries.entrySet()) + { + if(row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis()) + { + users.merge(row.getKey().user(), row.getValue(), (old, next) -> + (!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old); + } + } + return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName())).toList()); + } + + /******************************************************************************* + ** Only counts are exposed to the admin home, with no row contents or record ids. + *******************************************************************************/ + public Map counts() + { + expire(); + synchronized(this) + { + Map result = new java.util.TreeMap<>(); + entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, snapshotInternal(table, null).size())); + return (result); + } + } + + /******************************************************************************* + ** Record focus stays on its record channel; table events contain table snapshots. + *******************************************************************************/ + private void changed(String table, String pk) + { + if(pk != null) + { + emit("record:" + table + ":" + pk, table, pk); + } + emit("table:" + table, table, null); + } + + /******************************************************************************* + ** Maps keep the bus payload portable across in-memory and JSON ESB transports. + *******************************************************************************/ + private void emit(String channel, String table, String pk) + { + Map data = new LinkedHashMap<>(); + data.put("table", table); + if(pk != null) + { + data.put("pk", pk); + } + ArrayList> viewers = new ArrayList<>(); + synchronized(this) + { + for(Viewer viewer : snapshotInternal(table, pk)) + { + LinkedHashMap value = new LinkedHashMap<>(); + value.put("userId", viewer.userId()); + value.put("displayName", viewer.displayName()); + value.put("mode", viewer.mode()); + value.put("fieldName", viewer.fieldName()); + viewers.add(value); + } + } + data.put("viewers", viewers); + bus.publish(new RealtimeMessage().withChannel(channel).withEvent("presence.changed").withData(data)); + } + + /******************************************************************************* + ** Release listeners and timer on host shutdown or in tests. + *******************************************************************************/ + @Override + public void close() + { + if(expiry != null) + { + expiry.shutdownNow(); + } + try + { + subscription.close(); + } + catch(Exception e) + { + LOG.warn("Could not unsubscribe presence", e); + } + synchronized(this) + { + entries.clear(); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/presence/PresenceService.java b/src/main/java/com/kingsrook/qbits/collaboration/presence/PresenceService.java new file mode 100644 index 0000000..46c92ff --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/presence/PresenceService.java @@ -0,0 +1,151 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.presence; + +import java.util.List; +import com.kingsrook.qbits.collaboration.PersonalRecords; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver; +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.model.metadata.collaboration.RecordFeature; + +/******************************************************************************* + ** Authenticated, permission-checked boundary for presence; nothing is persisted. + *******************************************************************************/ +public class PresenceService +{ + /******************************************************************************* + ** Recheck visibility on every renewal and remove a refused caller's old lease. + *******************************************************************************/ + public List heartbeat(String connection, String table, String pk, String mode, String field) throws QException + { + String user = PersonalRecords.currentUserId(); + validateConnection(connection); + pk = canonicalKey(table, pk); + try + { + pk = authorize(table, pk); + } + catch(QException e) + { + registry().leave(user, connection, table, pk); + throw (e); + } + if(!List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode)) + { + throw (new QBadRequestException("Mode must be VIEWING or EDITING")); + } + if(field != null && (pk == null || QContext.getQInstance().getTable(table).getFields().get(field) == null)) + { + throw (new QBadRequestException("Unknown focused field")); + } + String name = QContext.getQSession().getUser().getFullName(); + if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, table, pk, mode, field)) + { + throw (new QBadRequestException("Presence capacity reached; retry later")); + } + return (registry().snapshot(table, pk)); + } + + /******************************************************************************* + ** Namespacing by user prevents one user from removing another user's connection. + *******************************************************************************/ + public void leave(String connection, String table, String pk) throws QException + { + String user = PersonalRecords.currentUserId(); + validateConnection(connection); + String canonical = authorize(table, pk); + registry().leave(user, connection, table, canonical); + } + + /******************************************************************************* + ** Polling has the same access boundary as heartbeat and SSE record subscriptions. + *******************************************************************************/ + public List snapshot(String table, String pk) throws QException + { + PersonalRecords.currentUserId(); + return (registry().snapshot(table, authorize(table, pk))); + } + + /******************************************************************************* + ** A refusal always has the same not-found result, including disabled features. + *******************************************************************************/ + private String authorize(String table, String pk) throws QException + { + if(!ParentRecordAccess.canReadTable(table) || !RecordFeaturesResolver.resolve(QContext.getQInstance(), QContext.getQInstance().getTable(table)).isEnabled(RecordFeature.PRESENCE)) + { + throw (new QNotFoundException("Not found")); + } + if(pk != null) + { + var record = ParentRecordAccess.requireVisibleParent(table, pk); + return (record.getValueString(QContext.getQInstance().getTable(table).getPrimaryKeyField())); + } + return (null); + } + + /******************************************************************************* + ** Canonicalize before the read check so a refused renewal removes an aliased key. + *******************************************************************************/ + private String canonicalKey(String tableName, String pk) throws QNotFoundException + { + if(pk == null) + { + return (null); + } + try + { + var table = QContext.getQInstance().getTable(tableName); + if(table == null || table.getPrimaryKeyField() == null) + { + throw (new IllegalArgumentException("Unknown table")); + } + return (com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), pk).toString()); + } + catch(RuntimeException e) + { + throw (new QNotFoundException("Not found")); + } + } + + /******************************************************************************* + ** Bound caller-controlled keys independently of the registry entry cap. + *******************************************************************************/ + private void validateConnection(String connection) throws QBadRequestException + { + if(connection == null || connection.isBlank() || connection.length() > 255) + { + throw (new QBadRequestException("A connectionId of at most 255 characters is required")); + } + } + + /******************************************************************************* + ** All endpoints on a pod share the eagerly installed instance registry. + *******************************************************************************/ + private PresenceRegistry registry() + { + return (PresenceRegistry.get(QContext.getQInstance())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java index fdb9bb7..ab1589a 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java @@ -55,7 +55,7 @@ public class CollaborationRowSecurity implements CustomPermissionChecker fields.addAll(java.util.List.of("id", "tableName", "recordId", "parentDeleted")); query.setFieldNamesToInclude(fields); } - if(!(metadata instanceof QTableMetaData table && table.getName().equals(CollaborationTables.RECORD_TAG))) + if(!(metadata instanceof QTableMetaData table && (table.getName().equals(CollaborationTables.RECORD_TAG) || table.getName().equals(CollaborationTables.FIELD_VALUE)))) { new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata); } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsMetaDataProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsMetaDataProvider.java index 65f5946..f4ac096 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsMetaDataProvider.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsMetaDataProvider.java @@ -21,7 +21,6 @@ package com.kingsrook.qbits.collaboration.tags; -import java.util.ArrayList; import java.util.List; import com.kingsrook.qbits.collaboration.FeatureSettingsProvider; import com.kingsrook.qbits.collaboration.model.CollaborationTables; @@ -30,14 +29,11 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; 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.RecordFeatureOverride; -import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData; 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.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; -import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* ** Contribute the Tags filter and quick-search field without changing host hooks. @@ -56,7 +52,7 @@ public class TagsMetaDataProvider implements RuntimeMetaDataProviderInterface { if(!table.getName().startsWith("collaboration_") && table.getPrimaryKeyField() != null && (table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_GET) || table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_QUERY)) - && enabled(instance, table, overrides)) + && FeatureSettingsProvider.enabled(instance, table, RecordFeature.TAGS, overrides)) { contribution.getTables().put(table.getName(), new RuntimeTableMetaDataContribution() .withVirtualFields(List.of(new QVirtualFieldMetaData("collaborationTags", QFieldType.STRING).withLabel("Tags") @@ -68,54 +64,4 @@ public class TagsMetaDataProvider implements RuntimeMetaDataProviderInterface return (contribution); } - /******************************************************************************* - ** Resolve TAGS against the incoming policy in core's instance/app/table order. - *******************************************************************************/ - private boolean enabled(QInstance instance, QTableMetaData table, List overrides) - { - List levels = new ArrayList<>(); - List 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(RecordFeature.TAGS))) - { - return (false); - } - enabled = level.getEnabled().getOrDefault(RecordFeature.TAGS, enabled); - } - } - for(var scope : RecordFeatureOverride.ScopeType.values()) - { - List 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() == RecordFeature.TAGS && 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); - } } diff --git a/src/main/resources/openapi/qqq-v1/collaboration.yaml b/src/main/resources/openapi/qqq-v1/collaboration.yaml index ef36c78..9cd4fda 100644 --- a/src/main/resources/openapi/qqq-v1/collaboration.yaml +++ b/src/main/resources/openapi/qqq-v1/collaboration.yaml @@ -1046,11 +1046,151 @@ info: contact: email: "contact@kingsrook.com" description: "Record collaboration: favorites, recents, attachments, tags, conversations,\ - \ following, timeline and people" + \ following, timeline, people and presence" title: "QQQ Middleware API extension: collaboration" version: "1.0.0" openapi: "3.0.3" paths: + /qqq/v1/qbit/collaboration/presence: + delete: + description: "Leave presence. Uses the signed-in caller's permissions and record\ + \ visibility." + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationPresenceLeaveV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationMutationResultV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Leave presence" + get: + description: "Get presence snapshot. Uses the signed-in caller's permissions\ + \ and record visibility." + parameters: + - description: "Parent table metadata name." + in: "query" + name: "table" + required: true + schema: + type: "string" + - description: "Parent record primary key." + in: "query" + name: "pk" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationPresenceSnapshotV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Get presence snapshot" + post: + description: "Renew presence. Uses the signed-in caller's permissions and record\ + \ visibility." + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationPresenceRequestV1" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Invalid input" + 401: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Sign in required" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Permission denied" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationErrorV1" + description: "Not found (including invisible parents)" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/CollaborationPresenceSnapshotV1" + description: "Successful Response" + security: + - sessionUuidCookie: + - "N/A" + summary: "Renew presence" /qqq/v1/qbit/collaboration/favorites: delete: description: "Unstar a target. Uses the signed-in caller's permissions and record\ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java index 28f6cb4..d8fc877 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiContractTest.java @@ -59,7 +59,7 @@ class CollaborationApiContractTest assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration")); assertThat(model.getPaths().keySet()).allMatch(pathName -> pathName.startsWith("/qqq/v1/qbit/collaboration/")); assertThat(generated).contains("multipart/form-data", "format: \"binary\"", "clients follow url", "nextCursor"); - assertThat(extension.getSpecs()).hasSize(42); + assertThat(extension.getSpecs()).hasSize(45); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java index 81b83f1..eef4021 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java @@ -166,6 +166,24 @@ class CollaborationApiHttpTest extends PersonalServiceFixture assertError(400, "POST", "/records/document/1/tasks", "{\"title\":5}", "alice"); } + /******************************************************************************* + ** Presence endpoints share parent checks and return typed merged snapshots. + *******************************************************************************/ + @Test + void presenceHttp() throws Exception + { + String body = "{\"connectionId\":\"tab-one\",\"table\":\"document\",\"pk\":\"1\",\"mode\":\"EDITING\",\"fieldName\":\"title\"}"; + assertThat(ok("POST", "/presence", body).get("viewers")).hasSize(1); + assertThat(ok("GET", "/presence?table=document&pk=1", null).at("/viewers/0/mode").asText()).isEqualTo("EDITING"); + assertThat(ok("GET", "/presence?table=document", null).at("/viewers/0/fieldName").isNull()).isTrue(); + assertError(404, "GET", "/presence?table=document&pk=3", null, "alice"); + assertError(404, "POST", "/presence", body, "outsider"); + assertError(400, "POST", "/presence", "{\"connectionId\":\"x\",\"table\":\"document\",\"mode\":\"BAD\"}", "alice"); + assertError(400, "GET", "/presence", null, "alice"); + assertThat(ok("DELETE", "/presence", "{\"connectionId\":\"tab-one\",\"table\":\"document\",\"pk\":\"1\"}").get("success").asBoolean()).isTrue(); + assertThat(ok("GET", "/presence?table=document&pk=1", null).get("viewers")).isEmpty(); + } + /******************************************************************************* ** Every personal operation and offset cursor runs through the HTTP boundary. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java index ff2ee5f..2c78dfe 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java @@ -114,7 +114,7 @@ class CollaborationTest CollaborationTables.SCOPE_TYPE, CollaborationTables.FEATURE, CollaborationTables.PERMISSION_MODE); assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12); assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName()) - .containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER); + .containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER); assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET); assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull(); assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD); @@ -177,9 +177,9 @@ class CollaborationTest QInstance headless = new QInstance(); headless.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); new CollaborationQBitProducer().withQBitConfig(config.withAdminApp(false)).produce(headless); - assertThat(headless.getTables()).hasSize(17); + assertThat(headless.getTables()).hasSize(19); assertThat(headless.getApps()).isEmpty(); - assertThat(headless.getWidgets()).hasSize(4); + assertThat(headless.getWidgets()).hasSize(5); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldSqlTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldSqlTest.java new file mode 100644 index 0000000..a2d3eec --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldSqlTest.java @@ -0,0 +1,223 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration; + +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.collaboration.customfields.BuildFieldIndexes; +import com.kingsrook.qbits.collaboration.customfields.CustomFieldStorage; +import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionProviderInterface; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** JDBC contract tests capture SQL and connection state without a live PostgreSQL server. + *******************************************************************************/ +public class CustomFieldSqlTest extends PersonalServiceFixture +{ + /******************************************************************************* + ** Use the real backend connection boundary with a deterministic driver double. + *******************************************************************************/ + private QRecord setupSql() throws Exception + { + admin(); + Jdbc.sql.clear(); + Jdbc.parameters.clear(); + Jdbc.failProbe = false; + Jdbc.failDdl = false; + Jdbc.usage = 0; + Jdbc.connections = 0; + ConnectionManager.resetConnectionProviders(); + instance.addBackend(new RDBMSBackendMetaData().withName("test-postgres").withVendor("postgres").withConnectionProvider(new QCodeReference(Jdbc.class))); + instance.getTable("document").withBackendName("test-postgres").withBackendDetails(new RDBMSTableBackendDetails().withTableName("tenant.orders")); + return (CollaborationRecords.insert(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("tableName", "document") + .withValue("name", "total").withValue("label", "Total").withValue("type", "DECIMAL").withValue("status", "ACTIVE") + .withValue("filterable", true).withValue("indexState", "NONE"))); + } + + /******************************************************************************* + ** Column absence is cached; retry after migration is explicit and executes once. + *******************************************************************************/ + @Test + void columnProbeIsCachedAndReportsFailures() throws Exception + { + setupSql(); + var table = instance.getTable("document"); + Jdbc.failProbe = true; + assertThat(CustomFieldStorage.availability(table).json()).isFalse(); + assertThat(CustomFieldStorage.availability(table).reason()).contains("column unavailable"); + assertThat(Jdbc.connections).isEqualTo(1); + Jdbc.failProbe = false; + CustomFieldStorage.invalidate(instance, "document"); + assertThat(CustomFieldStorage.availability(table).json()).isTrue(); + assertThat(Jdbc.connections).isEqualTo(2); + assertThat(Jdbc.sql).contains("SELECT qqq_custom_fields FROM \"tenant\".\"orders\" LIMIT 0"); + assertThatThrownBy(() -> CustomFieldStorage.identifier(" ")).isInstanceOf(IllegalArgumentException.class); + } + + /******************************************************************************* + ** Concurrent DDL requires autocommit and failures remain visible on the definition. + *******************************************************************************/ + @Test + void indexConnectionAndFailureStates() throws Exception + { + var definition = setupSql(); + BuildFieldIndexes.build(definition); + assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("indexState")).isEqualTo("READY"); + assertThat(Jdbc.sql).anyMatch(sql -> sql.contains("CREATE INDEX CONCURRENTLY") && sql.contains("::numeric")); + Jdbc.failDdl = true; + BuildFieldIndexes.build(definition); + assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("indexState")).isEqualTo("FAILED"); + assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("statusMessage")).contains("test DDL failure"); + Jdbc.failDdl = false; + definition.withValue("status", "ARCHIVED"); + BuildFieldIndexes.build(definition); + assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("indexState")).isEqualTo("NONE"); + assertThat(Jdbc.sql).anyMatch(sql -> sql.startsWith("DROP INDEX CONCURRENTLY")); + } + + /******************************************************************************* + ** Used stable ids cannot be removed; labels may change and new ids may be added. + *******************************************************************************/ + @Test + void activeOptionRemovalUsesBoundContainmentQuery() throws Exception + { + var definition = setupSql().withValue("type", "SELECT").withValue("options", "[{\"id\":\"a\",\"label\":\"Alpha\"}]"); + var changed = new QRecord(definition).withValue("options", "[]"); + Jdbc.usage = 1; + assertThatThrownBy(() -> FieldDefinitions.validate(changed, definition)).hasMessageContaining("still used"); + assertThat(Jdbc.sql).anyMatch(sql -> sql.contains("qqq_custom_fields @> ?::jsonb")); + assertThat(Jdbc.parameters.get(1)).isEqualTo("{\"total\":\"a\"}"); + FieldDefinitions.validate(new QRecord(definition).withValue("options", "[{\"id\":\"a\",\"label\":\"Renamed\"},{\"id\":\"b\",\"label\":\"Added\"}]"), definition); + Jdbc.usage = 0; + FieldDefinitions.validate(changed, definition); + definition.withValue("type", "MULTI_SELECT"); + changed.withValue("type", "MULTI_SELECT"); + FieldDefinitions.validate(changed, definition); + assertThat(Jdbc.parameters.get(1)).isEqualTo("total"); + assertThat(Jdbc.parameters.get(2)).isEqualTo("[\"a\"]"); + } + + /******************************************************************************* + ** Capture the JDBC boundary, including whether concurrent DDL was autocommitted. + *******************************************************************************/ + public static class Jdbc implements ConnectionProviderInterface + { + static final List sql = new ArrayList<>(); + static final Map parameters = new LinkedHashMap<>(); + static boolean failProbe; + static boolean failDdl; + static long usage; + static int connections; + + /******************************************************************************* + ** No external connection configuration is needed by the test double. + *******************************************************************************/ + @Override + public void init(RDBMSBackendMetaData backend) + { + } + + /******************************************************************************* + ** A new connection starts outside autocommit to verify the index job sets it. + *******************************************************************************/ + @Override + public Connection getConnection() + { + connections++; + boolean[] autoCommit = {false}; + return ((Connection) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{Connection.class}, (proxy, method, args) -> + { + return (switch(method.getName()) + { + case "setAutoCommit" -> + { + autoCommit[0] = (Boolean) args[0]; + yield null; + } + case "getAutoCommit" -> autoCommit[0]; + case "createStatement" -> statement(null, autoCommit); + case "prepareStatement" -> statement((String) args[0], autoCommit); + case "close" -> null; + default -> throw new UnsupportedOperationException(method.getName()); + }); + })); + } + + /******************************************************************************* + ** Only supported SQL operations are accepted; unexpected calls fail the test. + *******************************************************************************/ + private Object statement(String prepared, boolean[] autoCommit) + { + return (Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{prepared == null ? Statement.class : PreparedStatement.class}, (proxy, method, args) -> + { + if(method.getName().equals("close")) + { + return (null); + } + if(method.getName().equals("setString")) + { + parameters.put((Integer) args[0], (String) args[1]); + return (null); + } + String command = prepared == null ? (String) args[0] : prepared; + sql.add(command); + if(command.startsWith("SELECT qqq_custom_fields") && failProbe) + { + throw (new SQLException("column unavailable")); + } + if(method.getName().equals("execute")) + { + assertThat(autoCommit[0]).isTrue(); + if(failDdl) + { + throw (new SQLException("test DDL failure")); + } + return (false); + } + return (Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{ResultSet.class}, (result, operation, values) -> switch(operation.getName()) + { + case "next", "getBoolean" -> true; + case "getLong" -> usage; + case "close" -> null; + default -> throw new UnsupportedOperationException(operation.getName()); + })); + })); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java new file mode 100644 index 0000000..cdd3b53 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java @@ -0,0 +1,273 @@ +/* + * 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.math.BigDecimal; +import java.util.List; +import com.kingsrook.qbits.collaboration.customfields.BuildFieldIndexes; +import com.kingsrook.qbits.collaboration.customfields.CustomFieldTablesProvider; +import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions; +import com.kingsrook.qbits.collaboration.customfields.FieldProcesses; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; +import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Exercise designer rules and storage through ordinary core actions. + *******************************************************************************/ +public class CustomFieldsTest extends PersonalServiceFixture +{ + /******************************************************************************* + ** Create a real draft through the permission and pre-insert customizers. + *******************************************************************************/ + private QRecord draft(String name, String type) throws Exception + { + var result = new InsertAction().execute(new InsertInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of( + new QRecord().withValue("tableName", "document").withValue("name", name).withValue("label", name).withValue("type", type)))).getRecords().getFirst(); + assertThat(result.getErrors()).isNullOrEmpty(); + return (result); + } + + /******************************************************************************* + ** A published numeric JSON field is writable, filterable, validated and classified. + *******************************************************************************/ + @Test + void memoryFieldsUseNormalActionsAndPreserveClassification() throws Exception + { + admin(); + QRecord definition = draft("score", "INTEGER"); + var result = new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of( + new QRecord().withValue("id", definition.getValue("id")).withValue("minValue", 1).withValue("maxValue", 20).withValue("classification", "PHI") + .withValue("sectionName", "scores")))).getRecords().getFirst(); + assertThat(result.getErrors()).isNullOrEmpty(); + FieldDefinitions.apply(definition.getValue("id"), "Publish"); + var field = instance.getTable("document").getField("cf_score"); + assertThat(field.getBackendStorage()).isInstanceOf(JsonPathStorage.class); + assertThat(AiDataClassifications.ofField(instance.getTable("document"), "cf_score")).isEqualTo(DataClassification.PHI); + assertThat(instance.getTable("document").getSections()).anySatisfy(section -> assertThat(section.getFieldNames()).contains("cf_score")); + login("alice"); + var inserted = new InsertAction().execute(new InsertInput("document").withRecords(List.of(new QRecord().withValue("id", 4).withValue("tenant", "a") + .withValue("title", "Fourth").withValue("cf_score", 7)))).getRecords().getFirst(); + assertThat(inserted.getErrors()).isNullOrEmpty(); + assertThat(query("cf_score", QCriteriaOperator.GREATER_THAN, 5)).extracting(row -> row.getValueInteger("id")).containsExactly(4); + var updated = new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", 4).withValue("cf_score", 12)))).getRecords().getFirst(); + assertThat(updated.getErrors()).isNullOrEmpty(); + assertThat(query("cf_score", QCriteriaOperator.EQUALS, 12)).hasSize(1); + var invalid = new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", 4).withValue("cf_score", 30)))).getRecords().getFirst(); + assertThat(invalid.getErrors()).isNotEmpty(); + admin(); + assertThat(FieldDefinitions.valueCount(FieldDefinitions.get(definition.getValue("id")))).isEqualTo(1); + FieldDefinitions.apply(definition.getValue("id"), "Archive"); + assertThat(instance.getTable("document").getFields()).doesNotContainKey("cf_score"); + assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("status")).isEqualTo("ARCHIVED"); + } + + /******************************************************************************* + ** Normal permissions and direct table operations cannot bypass lifecycle rules. + *******************************************************************************/ + @Test + void validationPermissionsAndTypeRules() throws Exception + { + assertThatThrownBy(() -> draft("denied", "STRING")).hasMessageContaining("collaboration.fields.manage"); + login("manager", "collaboration.fields.manage", "collaboration.view"); + QRecord row = draft("choice", "SELECT"); + var duplicate = new InsertAction().execute(new InsertInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(new QRecord() + .withValue("tableName", "document").withValue("name", "choice").withValue("label", "Duplicate").withValue("type", "SELECT")))).getRecords().getFirst(); + assertThat(duplicate.getErrors()).isNotEmpty(); + assertThatThrownBy(() -> draft("Bad-name", "STRING")).hasMessageContaining("name"); + instance.getTable("document").withField(new QFieldMetaData("cf_clash", QFieldType.STRING)); + assertThatThrownBy(() -> draft("clash", "STRING")).hasMessageContaining("clash"); + instance.getTable("document").getFields().remove("cf_clash"); + assertThatThrownBy(() -> draft("link", "RECORD_LINK")).hasMessageContaining("possible value"); + assertThat(new CustomFieldTablesProvider().getPossibleValue("document")).isNotNull(); + assertThat(new CustomFieldTablesProvider().getPossibleValue("missing")).isNull(); + var changed = new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(new QRecord() + .withValue("id", row.getValue("id")).withValue("type", "STRING")))).getRecords().getFirst(); + assertThat(changed.getErrors()).isNullOrEmpty(); + FieldDefinitions.apply(row.getValue("id"), "Publish"); + assertThatThrownBy(() -> FieldDefinitions.apply(row.getValue("id"), "Publish")).hasMessageContaining("DRAFT"); + assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of( + new QRecord().withValue("id", row.getValue("id")).withValue("type", "INTEGER"))))).hasMessageContaining("DRAFT"); + assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of( + new QRecord().withValue("id", row.getValue("id")).withValue("status", "ARCHIVED"))))).hasMessageContaining("actions"); + assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(CollaborationTables.FIELD_DEFINITION).withPrimaryKeys(List.of(row.getValue("id"))))).hasMessageContaining("archive"); + assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("name", "renamed"), row)).hasMessageContaining("cannot change"); + assertThatThrownBy(() -> FieldDefinitions.options(new QRecord().withValue("options", "{}"))).hasMessageContaining("options"); + assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("classification", "BAD"), row)).hasMessageContaining("classification"); + assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("minValue", 5).withValue("maxValue", 1), row)).hasMessageContaining("Minimum"); + } + + /******************************************************************************* + ** Definitions contribute all supported types, options, search and defaults. + *******************************************************************************/ + @Test + void contributionMappingsAndGeneration() throws Exception + { + admin(); + long before = RuntimeMetaDataOverlay.getGeneration(instance); + for(String type : List.of("STRING", "TEXT", "DECIMAL", "BOOLEAN", "DATE", "DATE_TIME", "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK")) + { + QRecord row = new QRecord().withValue("tableName", "document").withValue("name", type.toLowerCase()).withValue("label", type) + .withValue("type", type).withValue("status", "ACTIVE").withValue("linkTableName", CollaborationTables.USER) + .withValue("helpText", "Helpful text").withValue("maxLength", type.equals("STRING") ? 30 : null) + .withValue("searchable", type.equals("STRING")).withValue("options", "[{\"id\":\"a\",\"label\":\"Alpha\"}]"); + CollaborationRecords.insert(CollaborationTables.FIELD_DEFINITION, row); + } + RuntimeMetaDataOverlay.refresh(instance); + assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isGreaterThan(before); + assertThat(instance.getTable("document").getField("cf_decimal").getType()).isEqualTo(QFieldType.DECIMAL); + assertThat(instance.getTable("document").getField("cf_multi_select").getType()).isEqualTo(QFieldType.STRING); + assertThat(instance.getTable("document").getSearchFields()).contains("cf_string"); + assertThat(instance.getTable("document").getField("cf_user").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER); + assertThat(instance.getTable("document").getField("cf_select").getPossibleValueSourceName()).startsWith("collaboration_field_options_"); + QRecord draft = draft("transaction", "STRING"); + QBackendTransaction tx = new QBackendTransaction(); + before = RuntimeMetaDataOverlay.getGeneration(instance); + new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withTransaction(tx).withRecords(List.of(new QRecord() + .withValue("id", draft.getValue("id")).withValue("label", "Updated")))); + assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isEqualTo(before); + tx.commit(); + assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isGreaterThan(before); + } + + /******************************************************************************* + ** Build migration SQL without opening a PostgreSQL connection or executing DDL. + *******************************************************************************/ + @Test + void managedFalseRecordsDdl() throws Exception + { + admin(); + QRecord row = draft("amount", "DECIMAL"); + row.withValue("filterable", true).withValue("status", "ACTIVE"); + var backend = new RDBMSBackendMetaData().withVendor("postgres").withName("postgres"); + instance.addBackend(backend); + instance.getTable("document").setBackendName("postgres"); + config.withManagedIndexes(false); + assertThat(config.getManagedIndexes()).isFalse(); + BuildFieldIndexes.build(row); + QRecord stored = FieldDefinitions.get(row.getValue("id")); + assertThat(stored.getValueString("indexState")).isEqualTo("NONE"); + assertThat(stored.getValueString("indexDdl")).contains("CREATE INDEX CONCURRENTLY IF NOT EXISTS", "qqq_custom_fields->>'amount'", "::numeric"); + assertThat(BuildFieldIndexes.ddl(instance.getTable("document"), new QRecord(row).withValue("type", "STRING").withValue("searchable", true), true)).contains("USING gin", "gin_trgm_ops"); + assertThat(BuildFieldIndexes.ddl(instance.getTable("document"), new QRecord(row).withValue("status", "ARCHIVED"), false)).startsWith("DROP INDEX CONCURRENTLY"); + assertThat(BuildFieldIndexes.ddl(instance.getTable("document"), new QRecord(row).withValue("filterable", false), false)).isNull(); + assertThat(CollaborationSchema.customFieldsDdl("orders")).contains("ADD COLUMN IF NOT EXISTS qqq_custom_fields JSONB", "jsonb_path_ops"); + assertThat(FieldProcesses.metadata()).extracting(process -> process.getLabel()).containsExactly("Publish field", "Archive field", "Rebuild index"); + } + + /******************************************************************************* + ** An unsupported JSON backend gets typed, read-only subquery filters. + *******************************************************************************/ + @Test + void eavFiltersAndSearch() throws Exception + { + admin(); + instance.addBackend(new RDBMSBackendMetaData().withName("fallback").withVendor("h2")); + instance.getTable("document").setBackendName("fallback"); + QRecord definition = draft("rating", "INTEGER"); + FieldDefinitions.apply(definition.getValue("id"), "Publish"); + instance.getTable("document").setBackendName("memory"); + var virtual = instance.getTable("document").getVirtualField("cf_rating"); + assertThat(virtual).isNotNull(); + assertThat(virtual.getIsQuerySelectable()).isFalse(); + assertThat(virtual.getIsEditable()).isFalse(); + assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("storage")).isEqualTo("EAV"); + CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "1") + .withValue("fieldName", "rating").withValue("valueNumber", new BigDecimal("7")).withValue("parentDeleted", false)); + login("alice"); + assertThat(query("cf_rating", QCriteriaOperator.GREATER_THAN, 5)).extracting(row -> row.getValueInteger("id")).containsExactly(1); + assertThat(query("cf_rating", QCriteriaOperator.NOT_EQUALS, 7)).extracting(row -> row.getValueInteger("id")).containsExactly(2); + assertThat(new QueryAction().execute(new QueryInput("document").withFilter(new QQueryFilter().withCriteria("cf_rating", QCriteriaOperator.IS_BLANK))).getRecords()) + .extracting(row -> row.getValueInteger("id")).containsExactly(2); + admin(); + assertThat(FieldDefinitions.valueCount(FieldDefinitions.get(definition.getValue("id")))).isEqualTo(1); + var expander = new com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander(); + assertThat(expander.expandSearchTerm(instance.getTable("document"), "text", com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind.TEXT, null)).isNotNull(); + assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("DATE")).isEqualTo("valueDate"); + assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("DATE_TIME")).isEqualTo("valueDateTime"); + assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("BOOLEAN")).isEqualTo("valueBoolean"); + assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("STRING")).isEqualTo("valueText"); + } + + /******************************************************************************* + ** Review and load execute the real lifecycle and retain failures in their summaries. + *******************************************************************************/ + @Test + void streamedActionsAndScheduledJob() throws Exception + { + admin(); + var row = draft("action", "STRING"); + var process = instance.getProcess("collaborationPublishField"); + var input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName(process.getName()).withRecords(List.of(row)); + var output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput(); + var review = new FieldProcesses.Review(); + review.runOnePage(input, output); + assertThat(output.getRecords()).hasSize(1); + assertThat(review.getProcessSummary(output, false)).hasSize(1); + var apply = new FieldProcesses.Apply(); + apply.runOnePage(input, output); + assertThat(apply.getProcessSummary(output, true)).hasSize(1); + review.runOnePage(input, output); + apply.runOnePage(input, output); + assertThat(review.getProcessSummary(output, false)).hasSize(2); + input.withProcessName("collaborationArchiveField"); + review.runOnePage(input, output); + FieldDefinitions.apply(row.getValue("id"), "Rebuild"); + new BuildFieldIndexes().run(input, output); + assertThat(BuildFieldIndexes.metadata(config.withSchedulerName("scheduler")).getSchedule()).isNotNull(); + config.withSchedulerName(null); + config.setManagedIndexes(true); + assertThat(config.getManagedIndexes()).isTrue(); + var draft = draft("delete", "STRING"); + new DeleteAction().execute(new DeleteInput(CollaborationTables.FIELD_DEFINITION).withPrimaryKeys(List.of(draft.getValue("id")))); + assertThatThrownBy(() -> FieldDefinitions.get(draft.getValue("id"))).hasMessageContaining("Not found"); + } + + /******************************************************************************* + ** Query paths remain ordinary filters; callers retain their record locks. + *******************************************************************************/ + private List query(String field, QCriteriaOperator operator, java.io.Serializable value) throws Exception + { + return (new QueryAction().execute(new QueryInput("document").withFilter(new QQueryFilter().withCriteria(field, operator, value))).getRecords()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java b/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java index 6150ce3..756456f 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java @@ -119,6 +119,8 @@ public abstract class PersonalServiceFixture @AfterEach public void tearDown() { + com.kingsrook.qbits.collaboration.presence.PresenceRegistry.close(instance); + com.kingsrook.qqq.backend.core.realtime.RealtimeBus.close(instance); QContext.clear(); MemoryRecordStore.getInstance().reset(); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/PresenceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/PresenceTest.java new file mode 100644 index 0000000..99a0611 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/PresenceTest.java @@ -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; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import com.kingsrook.qbits.collaboration.presence.PresenceRegistry; +import com.kingsrook.qbits.collaboration.presence.PresenceService; +import com.kingsrook.qbits.collaboration.app.CollaborationHome; +import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +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.realtime.InMemoryRealtimeBus; +import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Two independent pods, a real in-memory bus, controlled time and real permissions. + *******************************************************************************/ +class PresenceTest extends PersonalServiceFixture +{ + /******************************************************************************* + ** Renewals replicate even without a visible change; tabs merge until all expire. + *******************************************************************************/ + @Test + void twoPodsMergeRenewExpireAndLeave() throws Exception + { + var clock = new MutableClock(); + try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES); + var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES)) + { + List events = new CopyOnWriteArrayList<>(); + bus.subscribe(events::add); + first.heartbeat("alice", "Alice", "one", "document", "1", "VIEWING", null); + assertThat(second.snapshot("document", "1")).hasSize(1); + clock.advance(1000); + second.heartbeat("alice", "Alice", "two", "document", "1", "EDITING", "title"); + assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title")); + assertThat(first.snapshot("document", null).getFirst().fieldName()).isNull(); + int changes = (int) events.stream().filter(event -> event.getEvent().equals("presence.changed")).count(); + clock.advance(20000); + first.heartbeat("alice", "Alice", "two", "document", "1", "EDITING", "title"); + assertThat(events.stream().filter(event -> event.getEvent().equals("presence.changed")).count()).isEqualTo(changes); + clock.advance(25000); + first.expire(); + second.expire(); + assertThat(first.snapshot("document", "1")).hasSize(1); + assertThat(second.snapshot("document", "1")).hasSize(1); + second.leave("alice", "two", "document", "1"); + assertThat(first.snapshot("document", "1")).isEmpty(); + first.heartbeat("bob", "Bob", "three", "document", null, "VIEWING", null); + clock.advance(45000); + assertThat(second.snapshot("document", null)).isEmpty(); + assertThat(first.counts()).isEmpty(); + assertThat(events).anySatisfy(event -> + { + assertThat(event.getEvent()).isEqualTo("presence.heartbeat"); + assertThat(event.getChannel()).isEqualTo("instance:presence"); + }); + assertThat(events.stream().filter(event -> event.getChannel().equals("table:document"))).allSatisfy(event -> assertThat(event.getData()).doesNotContainKey("pk")); + } + } + + /******************************************************************************* + ** At capacity, existing leases renew while new targets are refused on both pods. + *******************************************************************************/ + @Test + void boundedRegistryAndMalformedMessages() throws Exception + { + var clock = new MutableClock(); + assertThat(PresenceRegistry.MAX_ENTRIES).isEqualTo(50000); + try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, 2); var second = new PresenceRegistry(bus, clock, 2)) + { + assertThat(first.heartbeat("alice", "Alice", "one", "document", "1", "VIEWING", null)).isTrue(); + assertThat(first.heartbeat("bob", "Bob", "one", "document", "1", "VIEWING", null)).isTrue(); + assertThat(second.heartbeat("carol", "Carol", "one", "document", "1", "VIEWING", null)).isFalse(); + assertThat(first.heartbeat("alice", "Alice", "one", "document", "1", "EDITING", "title")).isTrue(); + assertThat(second.counts()).containsEntry("document", 2); + bus.publish(new RealtimeMessage().withChannel(PresenceRegistry.INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(java.util.Map.of())); + assertThat(second.snapshot("document", "1")).hasSize(2); + clock.advance(45000); + assertThat(first.heartbeat("carol", "Carol", "one", "document", "1", "VIEWING", null)).isTrue(); + assertThat(first.snapshot("document", "1")).hasSize(1); + } + } + + /******************************************************************************* + ** Denials are opaque, current sessions are checked, and nothing enters the directory. + *******************************************************************************/ + @Test + void accessFeatureAndSnapshot() throws Exception + { + var service = new PresenceService(); + assertThat(service.heartbeat("one", "document", "01", "EDITING", "title")).hasSize(1); + assertThat(service.snapshot("document", "1")).extracting(PresenceRegistry.Viewer::userId).containsExactly("alice"); + assertThat(service.snapshot("document", null)).hasSize(1); + assertThatThrownBy(() -> service.heartbeat("one", "document", "3", "VIEWING", null)).isInstanceOf(QNotFoundException.class); + assertThatThrownBy(() -> service.snapshot("missing", null)).isInstanceOf(QNotFoundException.class); + assertThatThrownBy(() -> service.heartbeat("one", "document", "1", "BAD", null)).hasMessageContaining("Mode"); + assertThatThrownBy(() -> service.heartbeat("one", "document", "1", "VIEWING", "bad")).hasMessageContaining("field"); + assertThatThrownBy(() -> service.heartbeat("", "document", null, "VIEWING", null)).hasMessageContaining("connectionId"); + login("bob"); + service.leave("one", "document", "1"); + assertThat(service.snapshot("document", "1")).hasSize(1); + login("alice"); + QContext.getQSession().getSecurityKeyValues().clear(); + assertThatThrownBy(() -> service.heartbeat("one", "document", "1", "VIEWING", null)).isInstanceOf(QNotFoundException.class); + login("alice"); + assertThat(service.snapshot("document", "1")).isEmpty(); + service.heartbeat("table", "document", null, "VIEWING", null); + RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.PRESENCE, false); + RuntimeMetaDataOverlay.refresh(instance); + assertThatThrownBy(() -> service.snapshot("document", null)).isInstanceOf(QNotFoundException.class); + assertThatThrownBy(() -> service.heartbeat("table", "document", null, "VIEWING", null)).isInstanceOf(QNotFoundException.class); + admin(); + assertThat(CollaborationRecords.query(com.kingsrook.qbits.collaboration.model.CollaborationTables.USER, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty(); + assertThat(CollaborationHome.render(Instant.now())).contains("Who's here", "Custom fields", "Index NONE"); + } + + /******************************************************************************* + ** Clock controls the TTL boundary without sleeps. + *******************************************************************************/ + private static class MutableClock extends Clock + { + private Instant now = Instant.parse("2026-10-13T12:00:00Z"); + /******************************************************************************* + ** Move deterministic time forward. + *******************************************************************************/ + void advance(long millis) + { + now = now.plusMillis(millis); + } + + /******************************************************************************* + ** Fixed UTC test zone. + *******************************************************************************/ + @Override + public ZoneId getZone() + { + return (ZoneId.of("UTC")); + } + + /******************************************************************************* + ** Zone does not affect elapsed time. + *******************************************************************************/ + @Override + public Clock withZone(ZoneId zone) + { + return (this); + } + + /******************************************************************************* + ** Current test instant. + *******************************************************************************/ + @Override + public Instant instant() + { + return (now); + } + } +} -- 2.54.0 From 7b7c921079fb32ff6c93ef786127a97ed66c6fab Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 22:04:03 -0500 Subject: [PATCH 04/12] 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 --- README.md | 68 ++ .../customfields/CustomFieldsProvider.java | 2 +- .../qbits/collaboration/CustomFieldsTest.java | 24 + .../devserver/CollaborationDevServer.java | 681 ++++++++++++++++++ .../devserver/CollaborationDevServerTest.java | 231 ++++++ 5 files changed, 1005 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java diff --git a/README.md b/README.md index e0368fa..f229dc5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,74 @@ Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/ - Attachments require `qbit-object-storage:0.1.0-RC.2` (a pinned dependency), installed with a PRIVATE bucket alias matching `attachmentsBucketName`. Configure its connection and credentials in the host; this qbit does not provision buckets. - Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-SNAPSHOT`, targeting RC.6. +## Demo server + +`CollaborationDevServer` is a disposable, memory-backed Sales CRM for trying the +Next dashboard and taking screenshots. It needs Java 21 and the locally installed +QQQ `4.1.0-SNAPSHOT` from develop. No database, Docker or MinIO is needed. It binds +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. +Select a user with `-Dcollab.dev.user=alice|bob|admin` (one value, not the literal list) +and restart the server to switch. The MOCK session customizer supplies stable identities +and real permissions, also used by recipient access checks: + +| 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 Add the dependency (the snapshot must first be published by CI, or installed locally): diff --git a/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldsProvider.java b/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldsProvider.java index f4ff9ca..4b36687 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldsProvider.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/customfields/CustomFieldsProvider.java @@ -144,7 +144,7 @@ public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface case "SELECT", "MULTI_SELECT" -> { String source = "collaboration_field_options_" + definition.getValueInteger("id"); - instance.addPossibleValueSource(new QPossibleValueSource().withName(source).withType(QPossibleValueSourceType.ENUM) + instance.getPossibleValueSources().put(source, new QPossibleValueSource().withName(source).withType(QPossibleValueSourceType.ENUM) .withEnumValues(new ArrayList<>(FieldDefinitions.options(definition).values()))); field.withPossibleValueSourceName(source); if("MULTI_SELECT".equals(definition.getValueString("type"))) diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java index cdd3b53..2257aa8 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java @@ -57,6 +57,30 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; *******************************************************************************/ public class CustomFieldsTest extends PersonalServiceFixture { + /******************************************************************************* + ** Refreshing a SELECT must replace its generated vocabulary and allow later fields. + *******************************************************************************/ + @Test + void publishedSelectSurvivesRefreshAndOptionChanges() throws Exception + { + admin(); + QRecord choice = draft("contact", "SELECT"); + new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of( + new QRecord().withValue("id", choice.getValue("id")).withValue("options", "[{\"id\":\"email\",\"label\":\"Email\"}]")))); + FieldDefinitions.apply(choice.getValue("id"), "Publish"); + QRecord reference = draft("reference", "STRING"); + FieldDefinitions.apply(reference.getValue("id"), "Publish"); + assertThat(instance.getTable("document").getFields()).containsKeys("cf_contact", "cf_reference"); + new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of( + new QRecord().withValue("id", choice.getValue("id")).withValue("options", "[{\"id\":\"email\",\"label\":\"Email\"},{\"id\":\"phone\",\"label\":\"Phone\"}]")))); + RuntimeMetaDataOverlay.refresh(instance); + String source = instance.getTable("document").getField("cf_contact").getPossibleValueSourceName(); + assertThat(instance.getPossibleValueSource(source).getEnumValues()).hasSize(2); + assertThat(instance.getTable("document").getFields()).containsKeys("cf_contact", "cf_reference"); + } + + + /******************************************************************************* ** Create a real draft through the permission and pre-insert customizers. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java new file mode 100644 index 0000000..7ca24ff --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java @@ -0,0 +1,681 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.devserver; + + +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import javax.imageio.ImageIO; +import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; +import com.kingsrook.qbits.collaboration.CollaborationQBitProducer; +import com.kingsrook.qbits.collaboration.assignment.AssignmentService; +import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage; +import com.kingsrook.qbits.collaboration.attachments.AttachmentsService; +import com.kingsrook.qbits.collaboration.comments.CommentsService; +import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions; +import com.kingsrook.qbits.collaboration.favorites.FavoritesService; +import com.kingsrook.qbits.collaboration.follow.FollowService; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.notify.RecordingNotifier; +import com.kingsrook.qbits.collaboration.people.PeopleService; +import com.kingsrook.qbits.collaboration.presence.PresenceRegistry; +import com.kingsrook.qbits.collaboration.recents.RecentsService; +import com.kingsrook.qbits.collaboration.sharing.AccessRequestService; +import com.kingsrook.qbits.collaboration.sharing.PermissionHolderDirectory; +import com.kingsrook.qbits.collaboration.sharing.SharingService; +import com.kingsrook.qbits.collaboration.tags.TagsService; +import com.kingsrook.qbits.collaboration.tasks.TasksService; +import com.kingsrook.qbits.objectstorage.api.PresignGet; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay; +import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; +import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel; +import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordOwnership; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.realtime.RealtimeBus; +import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer; +import io.javalin.config.JavalinConfig; + + +/******************************************************************************* + ** Disposable Sales CRM for viewing collaboration in the Next dashboard. + ** Build the Next export with QQQ_NEXT_OUTPUT=export pnpm build, then place out/ + ** under EXPORT/next-dashboard/ (EXPORT, not next-dashboard, is on the classpath). + ** After mvnw.cmd test-compile dependency:build-classpath -Dmdep.includeScope=test + ** -Dmdep.outputFile=target/demo-classpath.txt, run: + ** java -Dqqq.javalin.frontend=next -Dcollab.dev.user=alice -Dcollab.dev.port=8098 + ** -cp "target/test-classes;target/classes;DEPS;EXPORT" com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer + ** DEPS is the content of target/demo-classpath.txt. See README for PowerShell. + *******************************************************************************/ +public class CollaborationDevServer extends AbstractQQQApplication +{ + private final QInstance instance; + static final List USERS = List.of("alice", "bob", "admin"); + + + + /******************************************************************************* + ** Keep the already seeded instance when Javalin initializes the application. + *******************************************************************************/ + public CollaborationDevServer(QInstance instance) + { + this.instance = instance; + } + + + + /******************************************************************************* + ** No HTTP server is needed to build or test the demo. + *******************************************************************************/ + @Override + public QInstance defineQInstance() + { + return (instance); + } + + + + /******************************************************************************* + ** Select a demo identity for this JVM; restarting resets all demo data. + *******************************************************************************/ + public static void main(String[] args) throws Exception + { + String user = System.getProperty("collab.dev.user", "alice"); + session(user); + int port = Integer.parseInt(System.getProperty("collab.dev.port", "8098")); + QInstance instance = buildInstance(); + seed(instance); + QApplicationJavalinServer server = server(instance, port, true); + server.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> + { + server.stop(); + PresenceRegistry.close(instance); + RealtimeBus.close(instance); + })); + System.out.println("COLLABORATION DEV SERVER READY http://localhost:" + port + "/?createMockSession=true"); + System.out.println("Person: http://localhost:" + port + "/app/person/1 Order: http://localhost:" + port + "/app/sales_order/1"); + System.out.println("Users (-Dcollab.dev.user): alice (Sales), bob (people read/comment), admin (all + collaboration.admin). Current: " + user); + Thread.currentThread().join(); + } + + + + /******************************************************************************* + ** Share the actual startup configuration with the HTTP smoke test. + *******************************************************************************/ + static QApplicationJavalinServer server(QInstance instance, int port, boolean frontend) + { + return (new QApplicationJavalinServer(new CollaborationDevServer(instance)).withPort(port) + .withServeFrontendNext(frontend).withServeFrontendMaterialDashboard(false) + .withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true) + .withJavalinConfigCustomizer(config -> + { + config.jetty.host = "127.0.0.1"; + MemoryStorage.routes(config); + })); + } + + + + /******************************************************************************* + ** Normal host metadata, plus the same collaboration installation as a real app. + *******************************************************************************/ + static QInstance buildInstance() throws Exception + { + QInstance instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData() + .withName("mock").withType(QAuthenticationType.MOCK).withCustomizer(new QCodeReference(DemoSessions.class))); + instance.setEnforcePermissionsInCoreActions(true); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + instance.addPossibleValueSource(new QPossibleValueSource().withName("sales_user").withType(QPossibleValueSourceType.ENUM) + .withEnumValues(List.of(new QPossibleValue<>("alice", "Alice Morgan"), new QPossibleValue<>("bob", "Bob Chen"), new QPossibleValue<>("admin", "Demo Administrator")))); + instance.addPossibleValueSource(new QPossibleValueSource().withName("sales_order_status").withType(QPossibleValueSourceType.ENUM) + .withEnumValues(List.of(new QPossibleValue<>("Draft"), new QPossibleValue<>("Confirmed"), new QPossibleValue<>("Processing"), new QPossibleValue<>("Shipped"), new QPossibleValue<>("Delivered")))); + instance.addTable(baseTable("person", "People", "person") + .withRecordLabelFormatAndFields("%s %s", "firstName", "lastName") + .withField(new QFieldMetaData("firstName", QFieldType.STRING).withIsRequired(true)) + .withField(new QFieldMetaData("lastName", QFieldType.STRING).withIsRequired(true)) + .withField(new QFieldMetaData("email", QFieldType.STRING)) + .withField(new QFieldMetaData("company", QFieldType.STRING)) + .withField(new QFieldMetaData("title", QFieldType.STRING)) + .withField(new QFieldMetaData("region", QFieldType.STRING)) + .withField(new QFieldMetaData("ownerUserId", QFieldType.STRING).withLabel("Account owner").withPossibleValueSourceName("sales_user")) + .withField(new QFieldMetaData("assigneeUserId", QFieldType.STRING).withLabel("Account partner").withPossibleValueSourceName("sales_user")) + .withSection(new QFieldSection("contact", new QIcon("person"), Tier.T1, List.of("id", "firstName", "lastName", "email")).withLabel("Contact")) + .withSection(new QFieldSection("account", new QIcon("business"), Tier.T2, List.of("company", "title", "region", "ownerUserId", "assigneeUserId")).withLabel("Account"))); + instance.addPossibleValueSource(QPossibleValueSource.newForTable("person")); + instance.addTable(baseTable("sales_order", "Sales orders", "receipt_long") + .withRecordLabelFormatAndFields("%s", "orderNo") + .withField(new QFieldMetaData("orderNo", QFieldType.STRING).withLabel("Order no.").withIsRequired(true)) + .withField(new QFieldMetaData("customer", QFieldType.INTEGER).withPossibleValueSourceName("person").withIsRequired(true)) + .withField(new QFieldMetaData("status", QFieldType.STRING).withPossibleValueSourceName("sales_order_status")) + .withField(new QFieldMetaData("total", QFieldType.DECIMAL)) + .withField(new QFieldMetaData("orderDate", QFieldType.DATE)) + .withSection(new QFieldSection("order", new QIcon("receipt_long"), Tier.T1, List.of("id", "orderNo", "customer", "status")).withLabel("Order")) + .withSection(new QFieldSection("commercial", new QIcon("payments"), Tier.T2, List.of("total", "orderDate")).withLabel("Commercial details"))); + instance.addApp(new QAppMetaData().withName("sales").withLabel("Sales").withIcon(new QIcon("storefront")) + .withSection(new QAppSection().withName("customers").withLabel("Customers & orders").withTables(List.of("person", "sales_order")))); + instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin").withIcon(new QIcon("settings")) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("admin"))); + new AuditsMetaDataProvider().withRecordIdType(QFieldType.STRING).withDefineRecordScreens(true) + .defineAll(instance, "memory", table -> table.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))); + new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory") + .withAdminApp(true).withParentAppName("admin").withUiApi(true) + .withTableFeatures("person", RecordFeature.values()).withTableFeatures("sales_order", RecordFeature.values()) + .withAttachmentStorage(new QCodeReference(MemoryStorage.class)) + .withUserSessionProvider(new QCodeReference(DemoSessions.class)) + .withPermissionHolderDirectory(new QCodeReference(DemoSessions.class)) + .withNotifier(new QCodeReference(RecordingNotifier.class)) + .withAssignActionTables(List.of("sales_order")) + .withFollowActionTables(List.of("person", "sales_order")).withFollowChangeTables(List.of("person", "sales_order")) + .withTagActionTables(List.of("person", "sales_order"))).produce(instance); + RecordFeaturesMetaData.ofOrWithNew(instance.getTable("person")).withSetting(RecordFeature.TAGS, "mode", "FREE") + .withOwnership(new RecordOwnership().withOwnerFieldName("ownerUserId").withAssigneeFieldName("assigneeUserId")); + RecordFeaturesMetaData.ofOrWithNew(instance.getTable("sales_order")).withSetting(RecordFeature.TAGS, "mode", "APPROVED_ONLY"); + QContext.init(instance, session("admin")); + new QInstanceEnricher(instance).enrich(); + new QInstanceValidator().validate(instance); + RuntimeMetaDataOverlay.refresh(instance); + return (instance); + } + + + + /******************************************************************************* + ** All Sales writes are permission checked and produce field-level audits. + *******************************************************************************/ + private static QTableMetaData baseTable(String name, String label, String icon) + { + return (new QTableMetaData().withName(name).withLabel(label).withIcon(new QIcon(icon)).withBackendName("memory") + .withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS)) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))); + } + + + + /******************************************************************************* + ** Stable grants shared by MOCK browser sessions, mentions and share checks. + *******************************************************************************/ + static QSession session(String user) + { + if(!USERS.contains(user)) + { + throw (new IllegalArgumentException("collab.dev.user must be alice, bob or admin: " + user)); + } + String name = switch(user) + { + case "alice" -> "Alice Morgan"; + case "bob" -> "Bob Chen"; + default -> "Demo Administrator"; + }; + QSession session = new QSession().withUser(new QUser().withIdReference(user).withFullName(name)) + .withPermissions("person.read", "person.comment"); + if(!user.equals("bob")) + { + session.withPermissions("audit.read", "auditDetail.read", "auditTable.read", "auditUser.read"); + for(String table : List.of("person", "sales_order")) + { + for(String permission : List.of("read", "write", "insert", "edit", "delete", "comment", "attach", "tag", "share", "task", "assign")) + { + session.withPermissions(table + "." + permission); + } + } + } + if(user.equals("admin")) + { + session.withPermissions("admin.access", "collaboration.admin", "collaboration.view", "collaboration.tags.manage", "collaboration.fields.manage"); + } + return (session); + } + + + + /******************************************************************************* + ** MOCK normally creates User:number / John Smith; replace both explicitly. + *******************************************************************************/ + public static class DemoSessions implements QAuthenticationModuleCustomizerInterface, UserSessionProviderInterface, PermissionHolderDirectory + { + /******************************************************************************* + ** Preserve the browser session UUID while applying the selected user's grants. + *******************************************************************************/ + @Override + public void customizeSession(QInstance instance, QSession target, Map context) + { + QSession selected = session(System.getProperty("collab.dev.user", "alice")); + target.setUser(selected.getUser()); + target.setPermissions(selected.getPermissions()); + } + + + + /******************************************************************************* + ** Return fresh sessions, so access checks cannot mutate another caller. + *******************************************************************************/ + @Override + public Optional sessionForUser(String userId) + { + return (USERS.contains(userId) ? Optional.of(session(userId)) : Optional.empty()); + } + + + + /******************************************************************************* + ** Access requests can find their configured approver without external IAM. + *******************************************************************************/ + @Override + public Set userIdsWithPermission(String permission) + { + return (USERS.stream().filter(user -> session(user).getPermissions().contains(permission)).collect(java.util.stream.Collectors.toSet())); + } + } + + + + /******************************************************************************* + ** Seed through normal actions/services, including lifecycle, access and audits. + *******************************************************************************/ + static void seed(QInstance instance) throws Exception + { + for(String user : USERS) + { + QContext.init(instance, session(user)); + new PeopleService().touch(); + } + QContext.setQSession(session("admin")); + publishField(new QRecord().withValue("tableName", "person").withValue("name", "preferred_contact").withValue("label", "Preferred contact") + .withValue("type", "SELECT").withValue("options", "[{\"id\":\"email\",\"label\":\"Email\"},{\"id\":\"phone\",\"label\":\"Phone\"},{\"id\":\"video\",\"label\":\"Video call\"}]")); + publishField(new QRecord().withValue("tableName", "sales_order").withValue("name", "purchase_order_no").withValue("label", "Purchase order no.") + .withValue("type", "STRING").withValue("searchable", true)); + seedSales(); + seedCollaboration(); + QContext.setQSession(session(System.getProperty("collab.dev.user", "alice"))); + } + + + + /******************************************************************************* + ** A published definition contributes actual memory JsonPathStorage fields. + *******************************************************************************/ + private static void publishField(QRecord definition) throws Exception + { + QRecord saved = insert(CollaborationTables.FIELD_DEFINITION, List.of(definition)).getFirst(); + FieldDefinitions.apply(saved.getValue("id"), "Publish"); + } + + + + /******************************************************************************* + ** Fictional but realistic accounts; stable IDs and random seed aid screenshots. + *******************************************************************************/ + private static void seedSales() throws Exception + { + String[] names = { "Maya Patel", "James Rivera", "Olivia Brooks", "Noah Kim", "Emma Wilson", "Liam Foster", "Ava Mitchell", "Ethan Reed", "Sofia Garcia", "Lucas Bennett", + "Isabella Turner", "Mason Wright", "Amelia Scott", "Logan Hayes", "Charlotte Evans", "Oliver Chen", "Harper Davis", "Elijah Morgan", "Evelyn Price", "Henry Lewis", + "Abigail Ross", "Jack Sullivan", "Grace Nguyen", "Daniel Park", "Zoe Campbell" }; + String[] companies = { "Northstar Outfitters", "Prairie Health Supply", "Harbor Coffee Roasters", "Summit Industrial", "Cedar Grove Foods", "Bluebird Logistics", "Riverbend Packaging", "Atlas Workshop", "Orchard Market", "Clearwater Labs" }; + String[] titles = { "Purchasing Director", "Operations Manager", "Founder", "Supply Chain Lead", "Finance Director" }; + String[] regions = { "Midwest", "Northeast", "West", "Southeast", "Southwest" }; + List people = new ArrayList<>(); + for(int i = 0; i < names.length; i++) + { + String[] name = names[i].split(" "); + people.add(new QRecord().withValue("id", i + 1).withValue("firstName", name[0]).withValue("lastName", name[1]) + .withValue("email", (name[0] + "." + name[1]).toLowerCase(java.util.Locale.ROOT) + "@example.com") + .withValue("company", companies[i % companies.length]).withValue("title", titles[i % titles.length]).withValue("region", regions[i % regions.length]) + .withValue("ownerUserId", "alice").withValue("assigneeUserId", i % 3 == 0 ? "bob" : "alice") + .withValue("cf_preferred_contact", i % 3 == 0 ? "phone" : "email")); + } + insert("person", people); + Random random = new Random(8098); + List orders = new ArrayList<>(); + String[] statuses = { "Draft", "Confirmed", "Processing", "Shipped", "Delivered" }; + for(int i = 0; i < 40; i++) + { + orders.add(new QRecord().withValue("id", i + 1).withValue("orderNo", "SO-2026-" + (1001 + i)).withValue("customer", i % 25 + 1) + .withValue("status", statuses[i % statuses.length]).withValue("total", BigDecimal.valueOf(75000 + random.nextInt(2400000), 2)) + .withValue("orderDate", LocalDate.of(2026, 9, 1).plusDays(i)).withValue("cf_purchase_order_no", "PO-" + (8400 + i))); + } + insert("sales_order", orders); + } + + + + /******************************************************************************* + ** Person 1 and order 1 contain the richest panels for the first demo visit. + *******************************************************************************/ + private static void seedCollaboration() throws Exception + { + TagsService tags = new TagsService(); + for(String table : List.of("person", "sales_order")) + { + for(String label : List.of("vip", "renewal", "at-risk", "new-business")) + { + var tag = tags.create(table, label, label.equals("at-risk") ? "red" : "blue"); + tags.apply(table, 1, List.of(tag.id()), List.of()); + tags.apply(table, label.equals("vip") ? 2 : 3, List.of(tag.id()), List.of()); + } + } + QContext.setQSession(session("alice")); + CommentsService comments = new CommentsService(); + comments.post("person", 1, "Renewal review is Thursday. @[Bob Chen](user:bob), please confirm Maya's preferred delivery window.", null, null); + QContext.setQSession(session("bob")); + comments.post("person", 1, "Confirmed: Tuesday mornings work best. @[Alice Morgan](user:alice), the warehouse contact is ready.", null, null); + QContext.setQSession(session("alice")); + var resolved = comments.post("person", 1, "Please check the purchasing title before sending the proposal.", null, "title"); + comments.post("person", 1, "Maya confirmed her new title on our call.", resolved.id(), null); + comments.resolve(resolved.id()); + comments.post("sales_order", 1, "Annual replenishment order. @[Demo Administrator](user:admin), please review the purchase order reference.", null, null); + comments.post("person", 2, "Expansion opportunity: add the two new Midwest locations to the renewal proposal.", null, null); + AttachmentsService attachments = new AttachmentsService(); + attachments.upload("person", 1, "northstar-account-plan.png", "image/png", new ByteArrayInputStream(picture()), "Account plan for the renewal review"); + attachments.upload("sales_order", 1, "order-confirmation.pdf", "application/pdf", new ByteArrayInputStream(pdf()), "Draft confirmation for SO-2026-1001"); + attachments.upload("sales_order", 2, "delivery-plan.png", "image/png", new ByteArrayInputStream(picture()), "Proposed delivery schedule"); + Instant now = Instant.now(); + TasksService tasks = new TasksService(); + tasks.create("person", 1, "Send the renewal proposal", "Include the expanded delivery schedule.", "alice", now.minusSeconds(86400), null); + tasks.create("person", 1, "Confirm warehouse contact", "Check the receiving team's direct number.", "bob", now.plusSeconds(172800), null); + tasks.create("sales_order", 1, "Approve purchase order reference", "Match the reference on the customer's PDF.", "alice", now.plusSeconds(86400), null); + var completed = tasks.create("person", 2, "Review regional pricing", "Use the current Midwest price list.", "alice", now.plusSeconds(86400), null); + tasks.complete(completed.getValueInteger("id")); + new AssignmentService().assign("sales_order", 1, "alice", "admin"); + new AssignmentService().assign("sales_order", 2, "alice", "alice"); + new SharingService().share("person", 1, List.of("bob"), "Please review the account before Thursday."); + new SharingService().share("sales_order", 1, List.of("bob"), "Please check the delivery plan."); + QContext.setQSession(session("bob")); + new AccessRequestService().request("sales_order", "1", "I need order access to coordinate the Northstar delivery.", null); + QContext.setQSession(session("alice")); + for(String table : List.of("person", "sales_order")) + { + for(int id = 1; id <= 3; id++) + { + new FollowService().follow(table, id, true, true); + FavoritesService.star("RECORD", table, id); + RecentsService.recordView("RECORD", table, id); + } + } + update("person", new QRecord().withValue("id", 1).withValue("title", "VP of Purchasing").withValue("cf_preferred_contact", "video")); + update("sales_order", new QRecord().withValue("id", 1).withValue("status", "Confirmed").withValue("total", new BigDecimal("18750.00"))); + update("sales_order", new QRecord().withValue("id", 2).withValue("status", "Processing")); + } + + + + /******************************************************************************* + ** Fail startup on row-level action errors instead of showing a partial demo. + *******************************************************************************/ + private static List insert(String table, List records) throws Exception + { + return (checked(new InsertAction().execute(new InsertInput(table).withRecords(records)).getRecords())); + } + + + + /******************************************************************************* + ** Ordinary updates generate real audit details and follow-change events. + *******************************************************************************/ + private static void update(String table, QRecord record) throws Exception + { + checked(new UpdateAction().execute(new UpdateInput(table).withRecords(List.of(record))).getRecords()); + } + + + + /******************************************************************************* + ** Both insert and update may return errors without throwing an exception. + *******************************************************************************/ + private static List checked(List records) + { + for(QRecord record : records) + { + if(record.getErrors() != null && !record.getErrors().isEmpty()) + { + throw (new IllegalStateException("Demo seed failed: " + record.getErrors())); + } + } + return (records); + } + + + + /******************************************************************************* + ** Generate a real image so previews need no downloaded assets. + *******************************************************************************/ + private static byte[] picture() throws IOException + { + BufferedImage image = new BufferedImage(640, 320, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + graphics.setColor(new Color(0x12324a)); + graphics.fillRect(0, 0, 640, 320); + graphics.setColor(new Color(0x55d6be)); + graphics.fillRect(40, 180, 160, 90); + graphics.fillRect(240, 130, 160, 140); + graphics.fillRect(440, 95, 160, 175); + graphics.setColor(Color.WHITE); + graphics.setFont(new Font("SansSerif", Font.BOLD, 26)); + graphics.drawString("Northstar | Growth plan", 40, 60); + graphics.dispose(); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ImageIO.write(image, "png", bytes); + return (bytes.toByteArray()); + } + + + + /******************************************************************************* + ** A small, valid PDF with a proper cross-reference table, generated locally. + *******************************************************************************/ + private static byte[] pdf() throws IOException + { + String content = "BT /F1 18 Tf 50 740 Td (Northstar Outfitters - SO-2026-1001) Tj 0 -35 Td (Order confirmation: USD 18,750.00) Tj ET\n"; + List objects = List.of("<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", "<< /Length " + content.length() + " >>\nstream\n" + content + "endstream"); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + bytes.write("%PDF-1.4\n".getBytes(StandardCharsets.US_ASCII)); + List offsets = new ArrayList<>(); + for(int i = 0; i < objects.size(); i++) + { + offsets.add(bytes.size()); + bytes.write(((i + 1) + " 0 obj\n" + objects.get(i) + "\nendobj\n").getBytes(StandardCharsets.US_ASCII)); + } + int start = bytes.size(); + bytes.write("xref\n0 6\n0000000000 65535 f \n".getBytes(StandardCharsets.US_ASCII)); + for(Integer offset : offsets) + { + bytes.write(String.format(java.util.Locale.ROOT, "%010d 00000 n \n", offset).getBytes(StandardCharsets.US_ASCII)); + } + bytes.write(("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n" + start + "\n%%EOF\n").getBytes(StandardCharsets.US_ASCII)); + return (bytes.toByteArray()); + } + + + + /******************************************************************************* + ** Adapt the attachment HTTP test's fake to real local downloads. Opaque expiring + ** links are issued only after AttachmentsService checks parent access. + *******************************************************************************/ + public static class MemoryStorage extends AttachmentStorage + { + private static final Map objects = new ConcurrentHashMap<>(); + private static final Map downloads = new ConcurrentHashMap<>(); + + /******************************************************************************* + ** Retain MIME type with the bytes for browser previews. + *******************************************************************************/ + private record StoredFile(byte[] bytes, String contentType) {} + + /******************************************************************************* + ** Capability URLs expire just like the object-storage URLs they substitute. + *******************************************************************************/ + private record Download(String key, String filename, Instant expires) {} + + + + /******************************************************************************* + ** The service enforces this cap while reading the upload stream. + *******************************************************************************/ + @Override + public long maxBytes(String name) + { + return (5 * 1024 * 1024); + } + + + + /******************************************************************************* + ** Same bounded-stream storage approach as CollaborationApiHttpTest.MemoryStorage. + *******************************************************************************/ + @Override + public void put(String name, PutRequest request) + { + try + { + objects.put(request.getKey(), new StoredFile(request.getStream().readAllBytes(), request.getContentType())); + } + catch(IOException e) + { + throw (new IllegalStateException("Cannot store demo attachment", e)); + } + } + + + + /******************************************************************************* + ** Deleting the object also makes any outstanding download links unusable. + *******************************************************************************/ + @Override + public void delete(String name, String key) + { + objects.remove(key); + } + + + + /******************************************************************************* + ** Issue a local URL on the configured demo port after the access check. + *******************************************************************************/ + @Override + public URL presign(String name, PresignGet request) + { + downloads.entrySet().removeIf(entry -> entry.getValue().expires().isBefore(Instant.now())); + String token = UUID.randomUUID().toString(); + downloads.put(token, new Download(request.getKey(), request.getDownloadAs(), Instant.now().plus(request.getTtl()))); + try + { + return (URI.create("http://localhost:" + System.getProperty("collab.dev.port", "8098") + "/demo-files/" + token).toURL()); + } + catch(Exception e) + { + throw (new IllegalStateException(e)); + } + } + + + + /******************************************************************************* + ** Serve only issued capabilities; never expose an object-key listing. + *******************************************************************************/ + static void routes(JavalinConfig config) + { + config.routes.get("/demo-files/{token}", context -> + { + Download download = downloads.get(context.pathParam("token")); + StoredFile file = download == null ? null : objects.get(download.key()); + if(download == null || !download.expires().isAfter(Instant.now()) || file == null) + { + context.status(404); + return; + } + context.contentType(file.contentType() == null ? "application/octet-stream" : file.contentType()); + context.header("Cache-Control", "no-store"); + context.header("X-Content-Type-Options", "nosniff"); + context.header("Content-Disposition", download.filename() == null ? "inline" : "attachment; filename*=UTF-8''" + java.net.URLEncoder.encode(download.filename(), StandardCharsets.UTF_8).replace("+", "%20")); + context.result(file.bytes()); + }); + } + + + + /******************************************************************************* + ** Keep the smoke test isolated from the other in-memory service tests. + *******************************************************************************/ + static void clear() + { + objects.clear(); + downloads.clear(); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java new file mode 100644 index 0000000..84230a0 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java @@ -0,0 +1,231 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.devserver; + + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kingsrook.qbits.collaboration.attachments.AttachmentsService; +import com.kingsrook.qbits.collaboration.favorites.FavoritesService; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.presence.PresenceRegistry; +import com.kingsrook.qbits.collaboration.recents.RecentsService; +import com.kingsrook.qbits.collaboration.sharing.SharingService; +import com.kingsrook.qbits.collaboration.tags.TagsService; +import com.kingsrook.qbits.collaboration.timeline.TimelineService; +import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature; +import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import com.kingsrook.qqq.backend.core.realtime.RealtimeBus; +import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer; +import com.kingsrook.qqq.middleware.javalin.QJavalinImplementation; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Exercise the real demo seed, permissions, audit history and local file URLs. + *******************************************************************************/ +class CollaborationDevServerTest +{ + /******************************************************************************* + ** Build without frontend assets, then smoke the same server on a free port. + *******************************************************************************/ + @Test + void seededDemoValidatesAndServesCollaboration() throws Exception + { + String oldUser = System.getProperty("collab.dev.user"); + String oldPort = System.getProperty("collab.dev.port"); + QInstance instance = null; + QApplicationJavalinServer server = null; + MemoryRecordStore.getInstance().reset(); + CollaborationDevServer.MemoryStorage.clear(); + try(HttpClient client = HttpClient.newHttpClient()) + { + System.setProperty("collab.dev.user", "alice"); + instance = CollaborationDevServer.buildInstance(); + CollaborationDevServer.seed(instance); + new QInstanceValidator().validate(instance); + assertThat(rows("person")).hasSize(25); + assertThat(rows("sales_order")).hasSize(40); + assertThat(rows("person")).anySatisfy(row -> + { + assertThat(row.getValueInteger("id")).isEqualTo(1); + assertThat(row.getValueString("cf_preferred_contact")).isEqualTo("video"); + }); + assertThat(rows("sales_order")).allSatisfy(row -> assertThat(row.getValueString("cf_purchase_order_no")).startsWith("PO-")); + assertThat(instance.getTable("person").getField("cf_preferred_contact").getBackendStorage()).isInstanceOf(JsonPathStorage.class); + assertThat(instance.getTable("sales_order").getField("cf_purchase_order_no").getBackendStorage()).isInstanceOf(JsonPathStorage.class); + assertThat(instance.getTable("sales_order").getSearchFields()).contains("cf_purchase_order_no"); + for(String table : List.of("person", "sales_order")) + { + var features = RecordFeaturesResolver.resolve(instance, instance.getTable(table)).getFeatures(); + for(RecordFeature feature : RecordFeature.values()) + { + assertThat(features.get(feature).getEnabled()).as(table + " " + feature).isTrue(); + } + assertThat(new TagsService().tagsFor(table, 1)).hasSize(4); + } + assertThat(new TagsService().mode("person")).isEqualTo("FREE"); + assertThat(new TagsService().mode("sales_order")).isEqualTo("APPROVED_ONLY"); + assertThat(FavoritesService.list()).hasSize(6); + assertThat(RecentsService.list(20)).hasSize(6); + assertThat(new TimelineService().timeline("person", 1, null, 100, null).items()) + .anySatisfy(item -> assertThat(item.details()).anySatisfy(detail -> + { + assertThat(detail.fieldName()).isEqualTo("title"); + assertThat(detail.oldValue()).isEqualTo("Purchasing Director"); + assertThat(detail.newValue()).isEqualTo("VP of Purchasing"); + })); + assertThat(new SharingService().preview("person", 1, List.of("bob")).getFirst().canRead()).isTrue(); + assertThat(new SharingService().preview("sales_order", 1, List.of("bob")).getFirst().canRead()).isFalse(); + QContext.setQSession(CollaborationDevServer.session("admin")); + assertThat(rows(CollaborationTables.USER)).hasSize(3); + assertThat(rows(CollaborationTables.FIELD_DEFINITION)).hasSize(2).allSatisfy(row -> assertThat(row.getValueString("status")).isEqualTo("ACTIVE")); + assertThat(rows(CollaborationTables.TAG)).hasSize(8); + assertThat(rows(CollaborationTables.ATTACHMENT)).hasSize(3); + assertThat(rows(CollaborationTables.COMMENT)).hasSize(6).anySatisfy(row -> assertThat(row.getValue("resolvedAt")).isNotNull()); + assertThat(rows(CollaborationTables.MENTION)).hasSize(3); + assertThat(rows(CollaborationTables.TASK)).hasSize(4).anySatisfy(row -> + { + assertThat(row.getValueString("status")).isEqualTo("OPEN"); + assertThat(row.getValueInstant("dueAt")).isBefore(Instant.now()); + }); + assertThat(rows(CollaborationTables.SHARE)).hasSize(2); + assertThat(rows(CollaborationTables.RECORD_ASSIGNMENT)).hasSize(2); + assertThat(rows(CollaborationTables.ACCESS_REQUEST)).singleElement().satisfies(row -> assertThat(row.getValueString("status")).isEqualTo("OPEN")); + assertThat(rows("auditDetail")).isNotEmpty(); + for(String user : CollaborationDevServer.USERS) + { + System.setProperty("collab.dev.user", user); + QSession mock = new MockAuthenticationModule().createSession(instance, Map.of("sessionUUID", UUID.randomUUID().toString())); + assertThat(mock.getUser().getIdReference()).isEqualTo(user); + assertThat(mock.getPermissions()).isEqualTo(CollaborationDevServer.session(user).getPermissions()); + } + assertThatThrownBy(() -> CollaborationDevServer.session("unknown")).isInstanceOf(IllegalArgumentException.class); + System.setProperty("collab.dev.user", "alice"); + int port; + try(ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + System.setProperty("collab.dev.port", Integer.toString(port)); + server = CollaborationDevServer.server(instance, port, false); + server.start(); + String base = "http://localhost:" + port; + assertThat(get(client, base + "/qqq/v1/version").statusCode()).isEqualTo(200); + var response = get(client, base + "/qqq/v1/qbit/collaboration/records/person/1/comments"); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(new ObjectMapper().readTree(response.body()).get("threads")).hasSize(3); + QContext.init(instance, CollaborationDevServer.session("alice")); + AttachmentsService attachments = new AttachmentsService(); + var png = attachments.list("person", 1).getFirst(); + var image = get(client, attachments.downloadUrl(png.id(), true).toString()); + assertThat(image.statusCode()).isEqualTo(200); + assertThat(image.headers().firstValue("Content-Type").orElseThrow()).startsWith("image/png"); + assertThat(javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(image.body()))).isNotNull(); + var pdf = attachments.list("sales_order", 1).getFirst(); + var document = get(client, attachments.downloadUrl(pdf.id(), false).toString()); + assertThat(document.statusCode()).isEqualTo(200); + assertThat(new String(document.body(), java.nio.charset.StandardCharsets.US_ASCII)).startsWith("%PDF-1.4"); + assertThat(document.headers().firstValue("Content-Disposition").orElseThrow()).contains("attachment", "order-confirmation.pdf"); + System.setProperty("collab.dev.user", "bob"); + assertThat(get(client, base + "/qqq/v1/qbit/collaboration/records/person/1/comments").statusCode()).isEqualTo(200); + assertThat(get(client, base + "/qqq/v1/qbit/collaboration/records/sales_order/1/comments").statusCode()).isEqualTo(404); + } + finally + { + if(server != null) + { + server.stop(); + } + if(instance != null) + { + PresenceRegistry.close(instance); + RealtimeBus.close(instance); + } + QContext.clear(); + QJavalinImplementation.setSessionCookieHttpOnly(false); + MemoryRecordStore.getInstance().reset(); + CollaborationDevServer.MemoryStorage.clear(); + restore("collab.dev.user", oldUser); + restore("collab.dev.port", oldPort); + } + } + + + + /******************************************************************************* + ** Read through normal permissions, including the seeded admin-only tables. + *******************************************************************************/ + private static List rows(String table) throws Exception + { + return (new QueryAction().execute(new QueryInput(table)).getRecords()); + } + + + + /******************************************************************************* + ** Each request gets a fresh MOCK session with the currently selected user. + *******************************************************************************/ + private static HttpResponse get(HttpClient client, String url) throws Exception + { + return (client.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=" + UUID.randomUUID()).GET().build(), HttpResponse.BodyHandlers.ofByteArray())); + } + + + + /******************************************************************************* + ** Do not leak demo properties into the rest of the test suite. + *******************************************************************************/ + private static void restore(String name, String value) + { + if(value == null) + { + System.clearProperty(name); + } + else + { + System.setProperty(name, value); + } + } +} -- 2.54.0 From a9baecd2930c4fd5d8b4dfc2875c0b6dd54c8bf4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 22:19:13 -0500 Subject: [PATCH 05/12] build: QQQ 4.1.0-RC.6 Co-Authored-By: Claude Opus 5.5 --- pom.xml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 8962254..1a26ce7 100644 --- a/pom.xml +++ b/pom.xml @@ -177,15 +177,14 @@ - + qqq-snapshot true - - 4.1.0-SNAPSHOT + 4.1.0-RC.6 -- 2.54.0 From 42cf51325a7326261a1b7acbb4f86b0f640c3e55 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:04:54 -0500 Subject: [PATCH 06/12] 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 --- .../tags/TagCriteriaExpander.java | 31 +++++++++++-- .../CollaborationApiHttpTest.java | 45 +++++++++++++++++++ .../qbits/collaboration/TagsServiceTest.java | 30 +++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagCriteriaExpander.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagCriteriaExpander.java index 4af0fd1..2f1ae25 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagCriteriaExpander.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagCriteriaExpander.java @@ -23,6 +23,7 @@ package com.kingsrook.qbits.collaboration.tags; import java.io.Serializable; import java.util.List; +import com.kingsrook.qbits.collaboration.CollaborationRecords; import com.kingsrook.qbits.collaboration.model.CollaborationTables; import com.kingsrook.qqq.backend.core.actions.tables.query.CriteriaExpansionContext; import com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind; @@ -71,7 +72,7 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac } /******************************************************************************* - ** Numeric values are picker IDs; strings are exact normalized vocabulary names. + ** Numeric values (or numeric strings naming an existing tag) are picker IDs; other strings are exact normalized names. *******************************************************************************/ private QQueryFilter membership(QTableMetaData table, List values, boolean negate, boolean any) throws QException { @@ -90,8 +91,8 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac { throw (new QUserFacingException("Tags cannot contain null values")); } - choices.withCriteria(value instanceof Number ? "id" : "name", QCriteriaOperator.EQUALS, - value instanceof Number ? value : TagsService.normalize(value.toString())); + Integer id = tagId(table, value); + choices.withCriteria(id != null ? "id" : "name", QCriteriaOperator.EQUALS, id != null ? id : TagsService.normalize(value.toString())); } tags.withSubFilter(choices); links.withCriteria("tagId", QCriteriaOperator.IN, new QSubQuery(CollaborationTables.TAG, "id", tags)); @@ -100,6 +101,30 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac new QSubQuery(CollaborationTables.RECORD_TAG, "recordId", links))); } + /******************************************************************************* + ** A value is a picker id when it is a Number (any JSON numeric type) or a String + ** holding an integer that matches an existing tag; otherwise it is a name (null). + *******************************************************************************/ + private Integer tagId(QTableMetaData table, Serializable value) throws QException + { + if(value instanceof Number number) + { + return (number.intValue()); + } + String text = value.toString().strip(); + try + { + int id = Integer.parseInt(text); + boolean exists = !CollaborationRecords.query(CollaborationTables.TAG, TagsService.vocabularyFilter(table.getName()) + .withCriteria("id", QCriteriaOperator.EQUALS, id)).isEmpty(); + return (exists ? id : null); + } + catch(NumberFormatException e) + { + return (null); + } + } + /******************************************************************************* ** Only live associations on this parent table participate. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java index eef4021..034649b 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java @@ -39,6 +39,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage; import com.kingsrook.qbits.objectstorage.api.PresignGet; import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay; import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; @@ -274,6 +275,50 @@ class CollaborationApiHttpTest extends PersonalServiceFixture assertError(400, "PUT", "/records/document/1/tags", "{\"add\":[1.5]}", "alice"); } + /******************************************************************************* + ** The picker sends tag ids through the v1 table query as any JSON number or string. + *******************************************************************************/ + @Test + void tagFilterByIdThroughTableQuery() throws Exception + { + RuntimeMetaDataOverlay.refresh(instance); + int a = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Alpha\"}").get("id").asInt(); + int b = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Beta\"}").get("id").asInt(); + ok("PUT", "/records/document/1/tags", "{\"add\":[" + a + "," + b + "]}"); + ok("PUT", "/records/document/2/tags", "{\"add\":[" + a + "]}"); + for(String ida : List.of(a + "", "\"" + a + "\"", a + ".0")) + { + assertThat(tagQuery("IN", "[" + ida + "]")).containsExactlyInAnyOrder(1, 2); + assertThat(tagQuery("EQUALS", "[" + ida + "]")).containsExactlyInAnyOrder(1, 2); + assertThat(tagQuery("NOT_IN", "[" + ida + "]")).isEmpty(); + } + for(String idb : List.of(b + "", "\"" + b + "\"", b + ".0")) + { + assertThat(tagQuery("CONTAINS_ALL", "[" + a + "," + idb + "]")).containsExactly(1); + assertThat(tagQuery("NOT_IN", "[" + idb + "]")).containsExactly(2); + } + assertThat(tagQuery("IN", "[\"alpha\"]")).containsExactlyInAnyOrder(1, 2); + } + + /******************************************************************************* + ** Run the v1 table query filtered on the virtual tags field; return matching ids. + *******************************************************************************/ + private List tagQuery(String operator, String values) throws Exception + { + String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/")); + String body = "{\"filter\":{\"criteria\":[{\"fieldName\":\"collaborationTags\",\"operator\":\"" + operator + "\",\"values\":" + values + "}],\"orderBys\":[{\"fieldName\":\"id\"}]}}"; + HttpResponse response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/query")).timeout(Duration.ofSeconds(20)) + .header("Cookie", "sessionUUID=alice" + sessionSuffix).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString()); + JsonNode result = json(response, 200); + List ids = new java.util.ArrayList<>(); + for(JsonNode record : result.get("records")) + { + ids.add(record.at("/values/id").asInt()); + } + return (ids); + } + /******************************************************************************* ** Comments, revisions, mentions, follow settings and the merged timeline. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java index c804a8a..b0e3352 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java @@ -445,6 +445,36 @@ public class TagsServiceTest extends PersonalServiceFixture { return ((List) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY)); } + /******************************************************************************* + ** JSON clients send ids as Integer, Long, BigDecimal or String; all select by id. + *******************************************************************************/ + @Test + void translatorAcceptsEveryIdRepresentation() throws Exception + { + TagEntry a = tags.create("document", "A", null); + TagEntry b = tags.create("document", "B", null); + TagEntry numeric = tags.create("document", "77", null); + tags.apply("document", 1, List.of(a.id(), b.id()), List.of()); + tags.apply("document", 2, List.of(a.id()), List.of()); + for(java.io.Serializable idA : List.of(a.id(), Long.valueOf(a.id()), new java.math.BigDecimal(a.id()), String.valueOf(a.id()))) + { + assertThat(recordIds("document", "IN", List.of(idA))).containsExactly("1", "2"); + assertThat(recordIds("document", "EQUALS", List.of(idA))).containsExactly("1", "2"); + assertThat(recordIds("document", "NOT_IN", List.of(idA))).isEmpty(); + } + for(java.io.Serializable idB : List.of(b.id(), Long.valueOf(b.id()), new java.math.BigDecimal(b.id()), String.valueOf(b.id()))) + { + assertThat(recordIds("document", "IN", List.of(idB))).containsExactly("1"); + assertThat(recordIds("document", "NOT_IN", List.of(idB))).containsExactly("2"); + assertThat(recordIds("document", "CONTAINS_ALL", List.of(String.valueOf(a.id()), idB))).containsExactly("1"); + } + assertThat(recordIds("document", "CONTAINS_ALL", List.of(new java.math.BigDecimal(a.id()), Long.valueOf(b.id())))).containsExactly("1"); + assertThat(recordIds("document", "IN", List.of("9999"))).isEmpty(); + tags.apply("document", 2, List.of(numeric.id()), List.of()); + assertThat(recordIds("document", "IN", List.of(String.valueOf(numeric.id())))).containsExactly("2"); + assertThat(recordIds("document", "IN", List.of("77"))).containsExactly("2"); + } + /******************************************************************************* ** Execute expanded criteria through the real parent query action. *******************************************************************************/ -- 2.54.0 From e696d9edaf6bc86c0e3f56967727cc5647de78db Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:07:19 -0500 Subject: [PATCH 07/12] 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 --- .../collaboration/CollaborationTest.java | 35 +++++++++++++++++++ .../devserver/CollaborationDevServer.java | 2 +- .../devserver/CollaborationDevServerTest.java | 34 ++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java index 2c78dfe..dbde066 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java @@ -168,6 +168,41 @@ class CollaborationTest + /******************************************************************************* + ** Without a parent app, Collaboration is a top-level node of the frontend app tree. + *******************************************************************************/ + @Test + void appTreeTopLevelWithoutParent() throws Exception + { + new QInstanceValidator().validate(instance); + var output = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction().execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput()); + assertThat(output.getApps()).containsKey("collaboration"); + assertThat(output.getAppTree()).extracting(node -> node.getName()).contains("collaboration"); + } + + + + /******************************************************************************* + ** With a parent app, Collaboration is nested under it and not duplicated at the top. + *******************************************************************************/ + @Test + void appTreeNestedUnderParent() throws Exception + { + instance = new QInstance(); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + instance.setEnforcePermissionsInCoreActions(true); + instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin").withIcon(new QIcon("settings"))); + new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory").withParentAppName("admin")).produce(instance); + QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("admin")).withPermissions(CollaborationPermissions.ADMIN)); + new QInstanceValidator().validate(instance); + var output = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction().execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput()); + assertThat(output.getAppTree()).extracting(node -> node.getName()).containsExactly("admin"); + assertThat(output.getAppTree().getFirst().getChildren()).extracting(node -> node.getName()).contains("collaboration"); + } + + + /******************************************************************************* ** Headless hosts keep the tables without app or widgets. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java index 7ca24ff..4382908 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java @@ -296,7 +296,7 @@ public class CollaborationDevServer extends AbstractQQQApplication } if(user.equals("admin")) { - session.withPermissions("admin.access", "collaboration.admin", "collaboration.view", "collaboration.tags.manage", "collaboration.fields.manage"); + session.withPermissions("admin.hasAccess", "collaboration.admin", "collaboration.view", "collaboration.tags.manage", "collaboration.fields.manage"); } return (session); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java index 84230a0..c631397 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java @@ -193,6 +193,40 @@ class CollaborationDevServerTest + /******************************************************************************* + ** The demo places Collaboration inside Admin, and the admin demo user can navigate to it. + *******************************************************************************/ + @Test + void collaborationIsInTheAppTreeUnderAdmin() throws Exception + { + MemoryRecordStore.getInstance().reset(); + CollaborationDevServer.MemoryStorage.clear(); + QInstance instance = CollaborationDevServer.buildInstance(); + try + { + QContext.init(instance, CollaborationDevServer.session("admin")); + var tree = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction() + .execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput()).getAppTree(); + var admin = tree.stream().filter(node -> node.getName().equals("admin")).findFirst(); + assertThat(admin).as("admin sees the Admin app").isPresent(); + assertThat(admin.get().getChildren()).extracting(node -> node.getName()).contains("collaboration"); + assertThat(tree).extracting(node -> node.getName()).doesNotContain("collaboration"); + QContext.init(instance, CollaborationDevServer.session("alice")); + var aliceTree = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction() + .execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput()).getAppTree(); + assertThat(aliceTree).extracting(node -> node.getName()).contains("sales").doesNotContain("admin", "collaboration"); + } + finally + { + PresenceRegistry.close(instance); + RealtimeBus.close(instance); + QContext.clear(); + MemoryRecordStore.getInstance().reset(); + } + } + + + /******************************************************************************* ** Read through normal permissions, including the seeded admin-only tables. *******************************************************************************/ -- 2.54.0 From 68fe96c22fce6b4081cf73fa7df3bd90fe42df15 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:14:27 -0500 Subject: [PATCH 08/12] 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 --- CHANGELOG.md | 5 + README.md | 2 +- .../CollaborationQBitProducer.java | 2 +- .../collaboration/app/CollaborationHome.java | 448 ++++++++++++------ .../collaboration/CollaborationTest.java | 69 ++- .../ConversationServicesTest.java | 3 +- .../qbits/collaboration/CustomFieldsTest.java | 22 + .../qbits/collaboration/HomeWidgetData.java | 66 +++ .../qbits/collaboration/PresenceTest.java | 8 +- .../qbits/collaboration/SharingWorkTest.java | 4 +- .../qbits/collaboration/TagsServiceTest.java | 9 +- .../attachments/AttachmentsServiceTest.java | 12 +- .../recents/RecentsServiceTest.java | 24 - 13 files changed, 458 insertions(+), 216 deletions(-) create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/HomeWidgetData.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fdf04..c422b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ## [Unreleased] +### Fixed + +- 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 diff --git a/README.md b/README.md index 26f3ffd..ed25572 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation All tables include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold. -The home reports persisted enabled settings by scope type, people known and people seen within the inclusive last seven days, plus Favorites and Recently viewed row counts and distinct user counts, attachment count and total bytes, comments in the last seven days, open root threads, and follower subscriptions (admins). Attachment aggregates are explicitly approximate until core can mirror parent locks. Recent activity is based on `lastSeenAt`, independently of the directory's `active` account flag. Empty states explain missing data. These counts do not represent resolved feature enablement on host tables. +The home is four full-width standard widgets (no custom HTML, no hard-coded colors, so dark mode works): an Overview of KPIs (comments this week, open threads, open and overdue tasks, open access requests, attachments with a human-readable total size such as 4.7 KB, active custom fields), a Tags table (label, table or All tables, records), a Custom fields table (name, table, status, index state) and an admin-only Who's here table of live viewer counts per table. Every block has a real empty-state message. Counts use the caller's session and permissions; attachment aggregates remain approximate until core can mirror parent locks. ## Permissions diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java index 2bee938..cacc8a6 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java @@ -162,7 +162,7 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer HOME_WIDGETS = List.of(KPI_WIDGET, TAGS_WIDGET, FIELDS_WIDGET, PRESENCE_WIDGET); + + public static final String NOBODY_HERE = "Nobody else is here right now."; + public static final String NO_TAGS = "No tags yet. Tags appear here once someone creates one on a record."; + public static final String NO_FIELDS = "No custom fields yet. Define one under Custom fields."; + public static final String PRESENCE_ADMIN_ONLY = "Who's here is visible to collaboration administrators."; @@ -81,7 +105,7 @@ public final class CollaborationHome { QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel("Collaboration").withIcon(new QIcon("forum")) .withPermissionRules(CollaborationPermissions.permissionRules()) - .withWidgets(List.of(HOME_WIDGET)) + .withWidgets(HOME_WIDGETS) .withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum")) .withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER))); app.setParentAppName(config.getParentAppName()); @@ -91,141 +115,235 @@ 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 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(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 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 KPI groups: conversations, work, files and custom fields. *******************************************************************************/ - public static String render(Instant now) throws QException + public static MultiStatisticsData kpis(Instant now) throws QException { CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession()); - StringBuilder html = new StringBuilder("

    Feature settings

    "); - long settings = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter()); - if(settings == 0) - { - html.append("

    No feature settings yet: features follow the code defaults

    "); - } - else - { - html.append("

    Enabled feature settings per scope:

      "); - 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("
    • ").append(scope).append(": ").append(enabled).append("
    • "); - } - html.append("

    Feature settings take effect after saving.

    "); - } - 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("

    People

    People known: ").append(people) - .append("

    Active in the last 7 days: ").append(recent).append("

    "); - if(people == 0) - { - html.append("

    No people yet: the directory will be filled by collaboration calls when the feature services are installed.

    "); - } - html.append("
    "); - personalSummary(html, CollaborationTables.FAVORITE, "Favorites"); - personalSummary(html, CollaborationTables.RECENT, "Recently viewed"); - attachmentSummary(html); - tagSummary(html); - fieldSummary(html); - html.append("

    Open access requests

    ").append(count(CollaborationTables.ACCESS_REQUEST, - new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("

    "); - html.append("

    Open tasks

    ").append(count(CollaborationTables.TASK, - new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("

    "); - html.append("

    Overdue tasks

    ").append(count(CollaborationTables.TASK, - new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN").withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now))).append("

    "); - html.append("

    Shares this week

    ").append(count(CollaborationTables.SHARE, - new QQueryFilter().withCriteria("sentAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS)) - .withCriteria("sentAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))).append("

    "); - html.append("

    Comments this week

    ").append(count(CollaborationTables.COMMENT, new QQueryFilter() - .withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS)) - .withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("

    "); - html.append("

    Open threads

    ").append(count(CollaborationTables.COMMENT, new QQueryFilter() - .withCriteria("threadId", QCriteriaOperator.IS_BLANK).withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK) - .withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("

    "); - if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN)) - { - html.append("

    Followers

    ").append(count(CollaborationTables.FOLLOW, new QQueryFilter())).append("

    "); - } - html.append("

    Conversation totals are approximate where parent record locks require post-filtering.

    "); - return (html.append("
    ").toString()); - } - - - - /******************************************************************************* - ** Caller-filtered vocabulary totals and five most used active tags. - *******************************************************************************/ - private static void tagSummary(StringBuilder html) throws QException - { - QQueryFilter active = new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK); - html.append("

    Tags

    Vocabulary size: ").append(count(CollaborationTables.TAG, active)) - .append("

    Pending suggestions: ").append(count(CollaborationTables.TAG, new QQueryFilter() - .withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK).withCriteria("approved", QCriteriaOperator.EQUALS, false))) - .append("

      "); - com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput input = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(CollaborationTables.TAG) - .withFilter(active.withCriteria("approved", QCriteriaOperator.EQUALS, true).withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(5)); - input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); - for(com.kingsrook.qqq.backend.core.model.data.QRecord tag : new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(input).getRecords()) - { - html.append("
    • ").append(tag.getValueString("label").replace("&", "&").replace("<", "<").replace(">", ">")) - .append(": ").append(tag.getValueInteger("usageCount")).append("
    • "); - } - html.append("
    "); - } - - /******************************************************************************* - ** Real table aggregates, restricted to readable parent tables. Parent locks - ** are post-filtered on rows, so these aggregate totals are approximate. - *******************************************************************************/ - private static void attachmentSummary(StringBuilder html) throws QException - { - Aggregate count = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT); + 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 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("

    Attachments

    Attachments: ").append(rowCount) - .append("

    Total size (bytes): ").append(totalBytes == null ? 0 : totalBytes) - .append("

    Approximate totals: parent record locks are applied when browsing attachments.

    "); - if("0".equals(String.valueOf(rowCount))) - { - html.append("

    No attachments yet.

    "); - } - html.append("
    "); + AggregateInput aggregateInput = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(files, bytes)); + aggregateInput.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + List 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 results = new AggregateAction().execute(input).getResults(); - html.append("

    ").append(label).append("

    Rows: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(rows)) - .append("

    Distinct users: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(users)).append("

    "); + List> rows = new ArrayList<>(); + for(QRecord tag : new QueryAction().execute(input).getRecords()) + { + Map 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", "3fr", "left"), column("Table", "table", "2fr", "left"), column("Records", "records", "1fr", "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> rows = new ArrayList<>(); + for(QRecord definition : new QueryAction().execute(input).getRecords()) + { + Map 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", "3fr", "left"), column("Table", "table", "2fr", "left"), + column("Status", "status", "1fr", "left"), column("Index state", "indexState", "1fr", "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> rows = new ArrayList<>(); + if(admin) + { + PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) -> + { + Map 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", "3fr", "left"), column("People", "viewers", "1fr", "right"))); + } + + + + /******************************************************************************* + ** Assemble a table widget whose empty state is a sentence, not a blank grid. + *******************************************************************************/ + private static TableData table(String label, String emptyMessage, List> 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 width, String align) + { + return (new TableData.Column("default", header, accessor, width, align)); + } + + + + /******************************************************************************* + ** A table's display label, "All tables" when there is none (global). + *******************************************************************************/ + private static String tableLabel(String name) + { + if(name == null || name.isBlank()) + { + return ("All tables"); + } + QTableMetaData table = QContext.getQInstance().getTable(name); + return (table == null || table.getLabel() == null ? name : table.getLabel()); + } + + + + /******************************************************************************* + ** ACTIVE becomes Active; a missing value becomes a dash. + *******************************************************************************/ + private static String friendly(String value) + { + if(value == null || value.isBlank()) + { + return ("-"); + } + String lower = value.toLowerCase(Locale.ROOT).replace('_', ' '); + return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1)); + } + + + + /******************************************************************************* + ** Binary sizes with one decimal, like 4.7 KB; whole bytes below 1 KB. + *******************************************************************************/ + public static String humanSize(long bytes) + { + if(bytes < 1024) + { + return (bytes + " B"); + } + String[] units = { "KB", "MB", "GB", "TB", "PB" }; + double value = bytes; + int unit = -1; + while(value >= 1024 && unit < units.length - 1) + { + value /= 1024; + unit++; + } + return (String.format(Locale.ROOT, "%.1f %s", value, units[unit])); + } + + + + /******************************************************************************* + ** Aggregate values come back as any numeric type, or null for empty sets. + *******************************************************************************/ + private static Number numberOf(Object value) + { + return (value instanceof Number number ? number : 0); + } + + + + /******************************************************************************* + ** The path of a Collaboration table page, under the parent app when configured. + *******************************************************************************/ + private static String path(String table) + { + CollaborationQBitConfig config = CollaborationRecords.config(); + String prefix = config.getParentAppName() == null ? "/" + config.getAppName() : "/" + config.getParentAppName() + "/" + config.getAppName(); + return (prefix + "/" + table); } @@ -233,7 +351,7 @@ public final class CollaborationHome /******************************************************************************* ** Count without materializing rows or substituting a privileged session. *******************************************************************************/ - private static long count(String table, QQueryFilter filter) throws QException + private static int count(String table, QQueryFilter filter) throws QException { CountInput input = new CountInput(table).withFilter(filter); input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); @@ -243,38 +361,9 @@ public final class CollaborationHome /******************************************************************************* - ** Real definition status and index totals, plus ephemeral admin-only counts. + ** KPI renderer instantiated by QQQ. *******************************************************************************/ - private static void fieldSummary(StringBuilder html) throws QException - { - html.append("

    Custom fields

      "); - for(String status : List.of("DRAFT", "ACTIVE", "ARCHIVED")) - { - html.append("
    • ").append(status).append(": ").append(count(CollaborationTables.FIELD_DEFINITION, - new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, status))).append("
    • "); - } - for(String state : List.of("NONE", "BUILDING", "READY", "FAILED")) - { - html.append("
    • Index ").append(state).append(": ").append(count(CollaborationTables.FIELD_DEFINITION, - new QQueryFilter().withCriteria("indexState", QCriteriaOperator.EQUALS, state))).append("
    • "); - } - html.append("

    Definitions with storage or index messages: ").append(count(CollaborationTables.FIELD_DEFINITION, - new QQueryFilter().withCriteria("statusMessage", QCriteriaOperator.IS_NOT_BLANK))).append(". See Custom Fields for details.

    "); - if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN)) - { - html.append("

    Who's here

      "); - com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, count) -> - html.append("
    • ").append(table.replace("&", "&").replace("<", "<").replace(">", ">")).append(": ").append(count).append("
    • ")); - html.append("
    "); - } - } - - - - /******************************************************************************* - ** Widget renderer instantiated by QQQ. - *******************************************************************************/ - public static class HomeRenderer extends AbstractWidgetRenderer + public static class KpiRenderer extends AbstractWidgetRenderer { /******************************************************************************* ** Render using the current session and time. @@ -282,7 +371,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())); } } } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java index dbde066..cb60be5 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java @@ -23,7 +23,6 @@ package com.kingsrook.qbits.collaboration; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.List; import com.kingsrook.qbits.collaboration.app.CollaborationHome; import com.kingsrook.qbits.collaboration.model.CollaborationTables; @@ -40,7 +39,8 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; -import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; @@ -115,8 +115,17 @@ class CollaborationTest assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12); assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName()) .containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER); - assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET); - assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull(); + assertThat(instance.getApp("collaboration").getWidgets()).containsExactlyElementsOf(CollaborationHome.HOME_WIDGETS); + for(String widget : CollaborationHome.HOME_WIDGETS) + { + assertThat(instance.getWidget(widget)).as(widget).isNotNull(); + assertThat(instance.getWidget(widget).getGridColumns()).isEqualTo(12); + assertThat(instance.getWidget(widget).getIsCard()).isFalse(); + } + assertThat(instance.getWidget(CollaborationHome.KPI_WIDGET).getType()).isEqualTo("multiStatistics"); + assertThat(instance.getWidget(CollaborationHome.TAGS_WIDGET).getType()).isEqualTo("table"); + assertThat(instance.getWidget(CollaborationHome.FIELDS_WIDGET).getType()).isEqualTo("table"); + assertThat(instance.getWidget(CollaborationHome.PRESENCE_WIDGET).getType()).isEqualTo("table"); assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD); assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getUniqueKeys().getFirst().getFieldNames()).containsExactly("scopeType", "scopeName", "feature"); assertThat(instance.getTable(CollaborationTables.USER).getUniqueKeys().getFirst().getFieldNames()).containsExactly("userId"); @@ -225,29 +234,39 @@ class CollaborationTest @Test void emptyHome() throws Exception { - String html = CollaborationHome.render(Instant.now()); - assertThat(html).contains("No feature settings yet: features follow the code defaults", "People known: 0", "Active in the last 7 days: 0", "No people yet"); - RawHTML widget = (RawHTML) new CollaborationHome.HomeRenderer().render(new RenderWidgetInput()).getWidgetData(); - assertThat(widget.getHtml()).contains("People known: 0"); + assertThat(HomeWidgetData.stats(Instant.now())).containsExactly( + java.util.Map.entry("Comments this week", 0), java.util.Map.entry("Open threads", 0), java.util.Map.entry("Open tasks", 0), + java.util.Map.entry("Overdue tasks", 0), java.util.Map.entry("Open access requests", 0), java.util.Map.entry("Attachments", 0), + java.util.Map.entry("Active custom fields", 0)); + assertThat(CollaborationHome.tags().getRows()).isEmpty(); + assertThat(CollaborationHome.tags().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_TAGS); + assertThat(CollaborationHome.fields().getRows()).isEmpty(); + assertThat(CollaborationHome.fields().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_FIELDS); + assertThat(CollaborationHome.presence().getRows()).isEmpty(); + assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo("Nobody else is here right now."); + assertThat(((TableData) new CollaborationHome.PresenceRenderer().render(new RenderWidgetInput()).getWidgetData()).getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NOBODY_HERE); + assertThat(new CollaborationHome.KpiRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(MultiStatisticsData.class); + assertThat(new CollaborationHome.TagsRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(TableData.class); + assertThat(new CollaborationHome.FieldsRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(TableData.class); + assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Work")).isEqualTo("Nothing waiting"); } /******************************************************************************* - ** Counts distinguish disabled settings and the inclusive seven-day window. + ** Tags and custom fields list real rows with table labels, All tables for global ones. *******************************************************************************/ @Test - void realCounts() throws Exception + void tablesListRealRows() throws Exception { - Instant now = Instant.parse("2026-10-13T12:00:00Z"); - seed(CollaborationTables.FEATURE_SETTING, - setting("INSTANCE", "instance", "COMMENTS", true), setting("APP", "sales", "TAGS", true), - setting("TABLE", "orders", "TASKS", true), setting("TABLE", "orders", "COMMENTS", true), - setting("TABLE", "orders", "PRESENCE", false)); - seed(CollaborationTables.USER, person("one", now), person("boundary", now.minus(7, ChronoUnit.DAYS)), - person("old", now.minus(7, ChronoUnit.DAYS).minusSeconds(1)), person("never", null), person("future", now.plusSeconds(1))); - String html = CollaborationHome.render(now); - assertThat(html).contains("INSTANCE: 1", "APP: 1", "TABLE: 2", "People known: 5", "Active in the last 7 days: 2").doesNotContain("No feature settings yet", "No people yet"); + seed(CollaborationTables.TAG, + new QRecord().withValue("name", "global").withValue("label", "Global").withValue("approved", true).withValue("createdByUserId", "admin").withValue("usageCount", 3), + new QRecord().withValue("name", "popular").withValue("label", "Popular").withValue("approved", true).withValue("createdByUserId", "admin").withValue("usageCount", 5)); + var tags = CollaborationHome.tags(); + assertThat(tags.getColumns()).extracting(TableData.Column::getHeader).containsExactly("Label", "Table", "Records"); + assertThat(tags.getRows()).hasSize(2); + assertThat(tags.getRows().get(0)).containsEntry("label", "Popular").containsEntry("table", "All tables").containsEntry("records", 5); + assertThat(tags.getRows().get(1)).containsEntry("label", "Global").containsEntry("table", "All tables").containsEntry("records", 3); } @@ -260,7 +279,7 @@ class CollaborationTest { QContext.getQSession().setPermissions(new java.util.HashSet<>()); assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.USER))).isInstanceOf(QPermissionDeniedException.class); - assertThatThrownBy(() -> CollaborationHome.render(Instant.now())).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> CollaborationHome.kpis(Instant.now())).isInstanceOf(QPermissionDeniedException.class); QContext.getQSession().withPermissions(CollaborationPermissions.VIEW); new QueryAction().execute(new QueryInput(CollaborationTables.FEATURE_SETTING)); new QueryAction().execute(new QueryInput(CollaborationTables.USER)); @@ -287,14 +306,16 @@ class CollaborationTest void homeUsesCurrentSession() throws Exception { Instant now = Instant.now(); - seed(CollaborationTables.USER, person("visible", now), person("hidden", now)); + seed(CollaborationTables.TAG, new QRecord().withValue("name", "visible").withValue("label", "Visible").withValue("approved", true).withValue("createdByUserId", "admin"), + new QRecord().withValue("name", "hidden").withValue("label", "Hidden").withValue("approved", true).withValue("createdByUserId", "admin")); instance.addSecurityKeyType(new QSecurityKeyType().withName("person").withAllAccessKeyName("allPeople")); - instance.getTable(CollaborationTables.USER).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("person").withFieldName("userId")); + instance.getTable(CollaborationTables.TAG).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("person").withFieldName("name")); QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("viewer")).withPermissions(CollaborationPermissions.VIEW).withSecurityKeyValue("person", "visible")); - assertThat(CollaborationHome.render(now)).contains("People known: 1", "Active in the last 7 days: 1"); + assertThat(CollaborationHome.tags().getRows()).singleElement().satisfies(row -> assertThat(row).containsEntry("label", "Visible")); + assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY); instance.setEnforcePermissionsInCoreActions(false); QContext.getQSession().setPermissions(new java.util.HashSet<>()); - assertThatThrownBy(() -> new CollaborationHome.HomeRenderer().render(new RenderWidgetInput())).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> new CollaborationHome.KpiRenderer().render(new RenderWidgetInput())).isInstanceOf(QPermissionDeniedException.class); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java index f8e3960..02f695e 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java @@ -507,8 +507,7 @@ public class ConversationServicesTest extends PersonalServiceFixture assertThat(sql).contains("ON collaboration_comment (table_name, record_id, create_date)", "ON collaboration_follow_digest (due_at)"); comments.post("document", 1, "home", null, null); admin(); - String html = com.kingsrook.qbits.collaboration.app.CollaborationHome.render(Instant.now().plusSeconds(1)); - assertThat(html).contains("Comments this week

    1", "Open threads

    1", "Followers

    1"); + assertThat(HomeWidgetData.stats(Instant.now().plusSeconds(1))).containsEntry("Comments this week", 1).containsEntry("Open threads", 1); assertThatThrownBy(() -> config.withFollowActionTables(List.of("missing")).validate(instance)).hasMessageContaining("host table"); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java index 2257aa8..d18fb5c 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CustomFieldsTest.java @@ -48,6 +48,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage; import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import com.kingsrook.qbits.collaboration.app.CollaborationHome; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -81,6 +83,26 @@ public class CustomFieldsTest extends PersonalServiceFixture + /******************************************************************************* + ** The home lists definitions with table label, status and index state, and counts active ones. + *******************************************************************************/ + @Test + void homeListsDefinitionsAndCountsActiveOnes() throws Exception + { + admin(); + QRecord definition = draft("region", "STRING"); + var drafts = CollaborationHome.fields(); + assertThat(drafts.getColumns()).extracting(TableData.Column::getHeader).containsExactly("Name", "Table", "Status", "Index state"); + assertThat(drafts.getRows()).singleElement().satisfies(row -> assertThat(row).containsEntry("name", "region").containsEntry("table", "Documents") + .containsEntry("status", "Draft").containsEntry("indexState", "None")); + assertThat(HomeWidgetData.stats(java.time.Instant.now())).containsEntry("Active custom fields", 0); + FieldDefinitions.apply(definition.getValue("id"), "Publish"); + assertThat(CollaborationHome.fields().getRows().getFirst()).containsEntry("status", "Active"); + assertThat(HomeWidgetData.stats(java.time.Instant.now())).containsEntry("Active custom fields", 1); + } + + + /******************************************************************************* ** Create a real draft through the permission and pre-insert customizers. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/HomeWidgetData.java b/src/test/java/com/kingsrook/qbits/collaboration/HomeWidgetData.java new file mode 100644 index 0000000..09b968c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/HomeWidgetData.java @@ -0,0 +1,66 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration; + + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import com.kingsrook.qbits.collaboration.app.CollaborationHome; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData; + + +/******************************************************************************* + ** Test view of the home KPI widget: statistic label to value, and group subheaders. + *******************************************************************************/ +public final class HomeWidgetData +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private HomeWidgetData() + { + } + + + + /******************************************************************************* + ** Every statistic on the KPI widget, by label. + *******************************************************************************/ + public static Map stats(Instant now) throws QException + { + Map result = new LinkedHashMap<>(); + CollaborationHome.kpis(now).getStatisticsGroupData().forEach(group -> group.getStatisticList().forEach(stat -> result.put(stat.getLabel(), stat.getValue()))); + return (result); + } + + + + /******************************************************************************* + ** The subheader of the named KPI group. + *******************************************************************************/ + public static String subheader(MultiStatisticsData data, String header) + { + return (data.getStatisticsGroupData().stream().filter(group -> header.equals(group.getHeader())).findFirst().orElseThrow().getSubheader()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/PresenceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/PresenceTest.java index 99a0611..a756ef0 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/PresenceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/PresenceTest.java @@ -134,13 +134,19 @@ class PresenceTest extends PersonalServiceFixture login("alice"); assertThat(service.snapshot("document", "1")).isEmpty(); service.heartbeat("table", "document", null, "VIEWING", null); + admin(); + assertThat(CollaborationHome.presence().getRows()).extracting(row -> row.get("table")).containsExactly("Documents"); + assertThat(CollaborationHome.presence().getRows().getFirst().get("viewers")).isEqualTo(1); + login("alice"); + assertThat(CollaborationHome.presence().getRows()).isEmpty(); + assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY); RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.PRESENCE, false); RuntimeMetaDataOverlay.refresh(instance); assertThatThrownBy(() -> service.snapshot("document", null)).isInstanceOf(QNotFoundException.class); assertThatThrownBy(() -> service.heartbeat("table", "document", null, "VIEWING", null)).isInstanceOf(QNotFoundException.class); admin(); assertThat(CollaborationRecords.query(com.kingsrook.qbits.collaboration.model.CollaborationTables.USER, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty(); - assertThat(CollaborationHome.render(Instant.now())).contains("Who's here", "Custom fields", "Index NONE"); + assertThat(CollaborationHome.fields().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_FIELDS); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java b/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java index 96af103..8dcbb5e 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java @@ -31,7 +31,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import com.kingsrook.qbits.collaboration.app.CollaborationHome; import com.kingsrook.qbits.collaboration.assignment.AssignmentService; import com.kingsrook.qbits.collaboration.comments.CommentsService; import com.kingsrook.qbits.collaboration.follow.FollowService; @@ -510,8 +509,7 @@ public class SharingWorkTest extends PersonalServiceFixture sharing.share("document", 1, List.of("bob"), null); access.request("document", 999, null, null); admin(); - String html = CollaborationHome.render(Instant.now().plusSeconds(5)); - assertThat(html).contains("Open access requests

    1", "Open tasks

    1", "Overdue tasks

    1", "Shares this week

    1"); + assertThat(HomeWidgetData.stats(Instant.now().plusSeconds(5))).containsEntry("Open access requests", 1).containsEntry("Open tasks", 1).containsEntry("Overdue tasks", 1); assertThat(SendTaskRemindersProcess.metadata(config).getIsHidden()).isTrue(); assertThat(SendTaskRemindersProcess.metadata(config).getSchedule()).isNull(); assertThat(SendTaskRemindersProcess.metadata(config.withSchedulerName("clock")).getSchedule().getRepeatSeconds()).isEqualTo(60); diff --git a/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java index b0e3352..85ca704 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java @@ -22,7 +22,6 @@ package com.kingsrook.qbits.collaboration; import java.io.Serializable; -import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -311,9 +310,11 @@ public class TagsServiceTest extends PersonalServiceFixture QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b")); assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_TAG)).getRecords()).isEmpty(); login("manager", "collaboration.admin", TagsService.MANAGE); - String html = CollaborationHome.render(Instant.now()); - assertThat(html).contains("Vocabulary size: 2", "Pending suggestions: 1", "<Urgent>: 1"); - assertThat(html).doesNotContain(""); + var home = CollaborationHome.tags(); + assertThat(home.getRows()).hasSize(2); + assertThat(home.getRows().getFirst()).containsEntry("label", "").containsEntry("table", "Documents").containsEntry("records", 1); + assertThat(home.getRows().getLast()).containsEntry("records", 0); + assertThat(home.getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_TAGS); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java index e7f8b2a..653a0d2 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java @@ -34,6 +34,7 @@ import java.util.List; import java.util.Map; import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; import com.kingsrook.qbits.collaboration.CollaborationSchema; +import com.kingsrook.qbits.collaboration.HomeWidgetData; import com.kingsrook.qbits.collaboration.PersonalServiceFixture; import com.kingsrook.qbits.collaboration.app.CollaborationHome; import com.kingsrook.qbits.collaboration.model.CollaborationTables; @@ -430,10 +431,17 @@ public class AttachmentsServiceTest extends PersonalServiceFixture void homeAndSchema() throws Exception { admin(); - assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 0", "No attachments yet.", "Total size (bytes): 0"); + assertThat(HomeWidgetData.stats(Instant.now())).containsEntry("Attachments", 0); + assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Attachments")).isEqualTo("No attachments yet"); upload("a.txt", "12345"); upload("b.txt", "123"); - assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 2", "Total size (bytes): 8", "Approximate totals").doesNotContain("No attachments yet."); + assertThat(HomeWidgetData.stats(Instant.now())).containsEntry("Attachments", 2); + assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Attachments")).isEqualTo("8 B in total"); + assertThat(CollaborationHome.humanSize(0)).isEqualTo("0 B"); + assertThat(CollaborationHome.humanSize(1023)).isEqualTo("1023 B"); + assertThat(CollaborationHome.humanSize(4813)).isEqualTo("4.7 KB"); + assertThat(CollaborationHome.humanSize(5L * 1024 * 1024)).isEqualTo("5.0 MB"); + assertThat(CollaborationHome.humanSize(3L * 1024 * 1024 * 1024)).isEqualTo("3.0 GB"); String ddl = CollaborationSchema.postgresScript(config); assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS collaboration_attachment", "size_bytes BIGINT", "parent_deleted BOOLEAN DEFAULT FALSE NOT NULL", "ON collaboration_attachment (table_name, record_id)").doesNotContain("preview_url", "download_url"); diff --git a/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java index d9aa188..cc9e342 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java @@ -33,11 +33,8 @@ import com.kingsrook.qbits.collaboration.CollaborationSchema; import com.kingsrook.qbits.collaboration.CollaborationSecurity; import com.kingsrook.qbits.collaboration.PersonalRecords; import com.kingsrook.qbits.collaboration.PersonalServiceFixture; -import com.kingsrook.qbits.collaboration.app.CollaborationHome; import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry; -import com.kingsrook.qbits.collaboration.favorites.FavoritesService; import com.kingsrook.qbits.collaboration.model.CollaborationTables; -import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; @@ -257,27 +254,6 @@ class RecentsServiceTest extends PersonalServiceFixture - /******************************************************************************* - ** Counts use the current user's owner lock and admins see real distinct users. - *******************************************************************************/ - @Test - void homeSummaryCounts() throws Exception - { - at(START); - login("alice", CollaborationPermissions.VIEW); - FavoritesService.star("TABLE", "document", null); - FavoritesService.star("RECORD", "document", "1"); - RecentsService.recordView("TABLE", "document", null); - login("bob", CollaborationPermissions.VIEW); - FavoritesService.star("TABLE", "document", null); - RecentsService.recordView("TABLE", "document", null); - assertThat(CollaborationHome.render(START)).contains("

    Favorites

    Rows: 1

    Distinct users: 1", "

    Recently viewed

    Rows: 1

    Distinct users: 1"); - admin(); - assertThat(CollaborationHome.render(START)).contains("

    Favorites

    Rows: 3

    Distinct users: 2", "

    Recently viewed

    Rows: 2

    Distinct users: 2"); - } - - - /******************************************************************************* ** Freeze the service clock, including exact boundary cases. *******************************************************************************/ -- 2.54.0 From 7e6331441314410f24756ca1d941b4788056527e Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:22:00 -0500 Subject: [PATCH 09/12] 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 --- .../api/CollaborationSchemas.java | 21 ++-- .../collaboration/api/CollaborationViews.java | 114 +++++++++++++++--- .../api/CollaborationWorkSchemas.java | 33 +++-- .../collaboration/api/CommentHistorySpec.java | 2 +- .../collaboration/api/CreateSharesSpec.java | 4 +- .../collaboration/api/GetFollowSpec.java | 2 +- .../collaboration/api/GetMyWorkSpec.java | 2 +- .../api/ListAttachmentsSpec.java | 2 +- .../collaboration/api/ListFollowersSpec.java | 2 +- .../api/ListMyAccessRequestsSpec.java | 2 +- .../collaboration/api/ListMyTasksSpec.java | 2 +- .../collaboration/api/ListTasksSpec.java | 2 +- .../model/CollaborationTables.java | 55 +++++---- .../collaboration/people/PeopleService.java | 31 +++++ .../openapi/qqq-v1/collaboration.yaml | 48 ++++++++ .../CollaborationApiHttpTest.java | 71 ++++++++++- .../collaboration/CollaborationTest.java | 20 +++ 17 files changed, 338 insertions(+), 75 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java index 4e16d50..e455440 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationSchemas.java @@ -70,7 +70,7 @@ public final class CollaborationSchemas /******************************************************************************* ** An attachment visible through its parent record; no storage coordinates are exposed. *******************************************************************************/ - public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete) implements ToSchema + public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName) implements ToSchema { /******************************************************************************* ** Describe this versioned body for generated clients. @@ -89,7 +89,8 @@ public final class CollaborationSchemas Map.entry("description", new Schema().withType(Type.STRING).withDescription("Optional description.").withNullable(true)), Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("Uploader identity.").withNullable(true)), Map.entry("createDate", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Upload time.").withNullable(true)), - Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true)))))); + Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true)), + Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Uploader display name.").withNullable(true)))))); } } @@ -201,7 +202,7 @@ public final class CollaborationSchemas /******************************************************************************* ** A previous comment body. *******************************************************************************/ - public record CollaborationCommentRevisionV1(Integer id, String body, Instant editedAt, String editedByUserId) implements ToSchema + public record CollaborationCommentRevisionV1(Integer id, String body, Instant editedAt, String editedByUserId, String editedByDisplayName) implements ToSchema { /******************************************************************************* ** Describe this versioned body for generated clients. @@ -213,14 +214,15 @@ public final class CollaborationSchemas Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Revision identifier.").withNullable(true)), Map.entry("body", new Schema().withType(Type.STRING).withDescription("Previous body text.").withNullable(true)), Map.entry("editedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Time the body was replaced.").withNullable(true)), - Map.entry("editedByUserId", new Schema().withType(Type.STRING).withDescription("Editor identity.").withNullable(true)))))); + Map.entry("editedByUserId", new Schema().withType(Type.STRING).withDescription("Editor identity.").withNullable(true)), + Map.entry("editedByDisplayName", new Schema().withType(Type.STRING).withDescription("Editor display name.").withNullable(true)))))); } } /******************************************************************************* ** A subscription to record changes and comments. *******************************************************************************/ - public record CollaborationFollowV1(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil) implements ToSchema + public record CollaborationFollowV1(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil, String userDisplayName) implements ToSchema { /******************************************************************************* ** Describe this versioned body for generated clients. @@ -234,7 +236,8 @@ public final class CollaborationSchemas Map.entry("reason", new Schema().withType(Type.STRING).withEnumValues(List.of("MANUAL", "AUTHOR", "COMMENTED", "MENTIONED", "ASSIGNED", "SHARED")).withDescription("How following began.").withNullable(true)), Map.entry("notifyOnChange", new Schema().withType(Type.BOOLEAN).withDescription("Receive record change digests.").withNullable(true)), Map.entry("notifyOnComment", new Schema().withType(Type.BOOLEAN).withDescription("Receive new comment notifications.").withNullable(true)), - Map.entry("mutedUntil", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Notifications muted until this time, if set.").withNullable(true)))))); + Map.entry("mutedUntil", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Notifications muted until this time, if set.").withNullable(true)), + Map.entry("userDisplayName", new Schema().withType(Type.STRING).withDescription("Follower display name.").withNullable(true)))))); } } @@ -678,14 +681,14 @@ public final class CollaborationSchemas { Map schemas = new TreeMap<>(); schemas.put("CollaborationFavoriteV1", new CollaborationFavoriteV1(null, null, null, null, null, null, null, null, null).toSchema()); - schemas.put("CollaborationAttachmentV1", new CollaborationAttachmentV1(null, null, null, null, null, null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationAttachmentV1", new CollaborationAttachmentV1(null, null, null, null, null, null, null, null, null, null, null, null).toSchema()); schemas.put("CollaborationTagV1", new CollaborationTagV1(null, null, null, null, null, null, null).toSchema()); schemas.put("CollaborationPersonV1", new CollaborationPersonV1(null, null, null, null).toSchema()); schemas.put("CollaborationMentionV1", new CollaborationMentionV1(null, null).toSchema()); schemas.put("CollaborationMentionCandidateV1", new CollaborationMentionCandidateV1(null, null).toSchema()); schemas.put("CollaborationCommentV1", new CollaborationCommentV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema()); - schemas.put("CollaborationCommentRevisionV1", new CollaborationCommentRevisionV1(null, null, null, null).toSchema()); - schemas.put("CollaborationFollowV1", new CollaborationFollowV1(null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationCommentRevisionV1", new CollaborationCommentRevisionV1(null, null, null, null, null).toSchema()); + schemas.put("CollaborationFollowV1", new CollaborationFollowV1(null, null, null, null, null, null, null).toSchema()); schemas.put("CollaborationFollowStateV1", new CollaborationFollowStateV1(null, null).toSchema()); schemas.put("CollaborationTimelineDetailV1", new CollaborationTimelineDetailV1(null, null, null, null).toSchema()); schemas.put("CollaborationTimelineItemV1", new CollaborationTimelineItemV1(null, null, null, null, null, null, null, null, null).toSchema()); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java index 7f90bd8..12f48b8 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationViews.java @@ -22,6 +22,10 @@ package com.kingsrook.qbits.collaboration.api; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationAttachmentV1; import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationCommentRevisionV1; import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationCommentV1; @@ -40,10 +44,13 @@ import com.kingsrook.qbits.collaboration.comments.MentionCandidate; import com.kingsrook.qbits.collaboration.comments.MentionEntry; import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry; import com.kingsrook.qbits.collaboration.follow.FollowEntry; +import com.kingsrook.qbits.collaboration.people.PeopleService; import com.kingsrook.qbits.collaboration.people.PersonEntry; import com.kingsrook.qbits.collaboration.tags.TagEntry; import com.kingsrook.qbits.collaboration.timeline.TimelineDetail; import com.kingsrook.qbits.collaboration.timeline.TimelineItem; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; /******************************************************************************* @@ -67,13 +74,39 @@ final class CollaborationViews value.id(), value.targetType(), value.targetName(), value.recordId(), value.label(), value.folder(), value.sortOrder(), value.available(), value.href())); } + /******************************************************************************* + ** One display name per distinct user id, from a single directory query. + *******************************************************************************/ + static Map 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 names, String userId) + { + return (userId == null ? null : names.getOrDefault(userId, userId)); + } + /******************************************************************************* ** Project attachment display data. *******************************************************************************/ - static CollaborationAttachmentV1 attachment(AttachmentEntry value) + static CollaborationAttachmentV1 attachment(AttachmentEntry value) throws QException { - return (new CollaborationAttachmentV1( - value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete())); + return (attachments(List.of(value)).getFirst()); + } + + /******************************************************************************* + ** Project attachments, resolving uploader names in one batch. + *******************************************************************************/ + static List attachments(List values) throws QException + { + Map names = names(values.stream().map(AttachmentEntry::createdByUserId).toArray(String[]::new)); + return (values.stream().map(value -> new CollaborationAttachmentV1( + value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete(), + name(names, value.createdByUserId()))).toList()); } /******************************************************************************* @@ -115,19 +148,29 @@ final class CollaborationViews /******************************************************************************* ** Project commentrevision display data. *******************************************************************************/ - static CollaborationCommentRevisionV1 commentRevision(CommentRevision value) + static List commentRevisions(List values) throws QException { - return (new CollaborationCommentRevisionV1( - value.id(), value.body(), value.editedAt(), value.editedByUserId())); + Map names = names(values.stream().map(CommentRevision::editedByUserId).toArray(String[]::new)); + return (values.stream().map(value -> new CollaborationCommentRevisionV1( + value.id(), value.body(), value.editedAt(), value.editedByUserId(), name(names, value.editedByUserId()))).toList()); } /******************************************************************************* ** Project follow display data. *******************************************************************************/ - static CollaborationFollowV1 follow(FollowEntry value) + static CollaborationFollowV1 follow(FollowEntry value) throws QException { - return (new CollaborationFollowV1( - value.id(), value.userId(), value.reason(), value.notifyOnChange(), value.notifyOnComment(), value.mutedUntil())); + return (follows(List.of(value)).getFirst()); + } + + /******************************************************************************* + ** Project followers, resolving names in one batch. + *******************************************************************************/ + static List follows(List values) throws QException + { + Map names = names(values.stream().map(FollowEntry::userId).toArray(String[]::new)); + return (values.stream().map(value -> new CollaborationFollowV1( + value.id(), value.userId(), value.reason(), value.notifyOnChange(), value.notifyOnComment(), value.mutedUntil(), name(names, value.userId()))).toList()); } /******************************************************************************* @@ -159,33 +202,70 @@ final class CollaborationViews /******************************************************************************* ** Project Share public fields. *******************************************************************************/ - static CollaborationWorkSchemas.CollaborationShareV1 share(com.kingsrook.qqq.backend.core.model.data.QRecord row) + static CollaborationWorkSchemas.CollaborationShareV1 share(QRecord row) throws QException { - return (new CollaborationWorkSchemas.CollaborationShareV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("fromUserId"), row.getValueString("toUserId"), row.getValueString("message"), row.getValueBoolean("recipientCouldRead"), row.getValueInstant("sentAt"))); + return (shares(List.of(row)).getFirst()); + } + + /******************************************************************************* + ** Project shares, resolving sender and recipient names in one batch. + *******************************************************************************/ + static List shares(List rows) throws QException + { + Map names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("fromUserId"), row.getValueString("toUserId"))).toArray(String[]::new)); + return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationShareV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), + row.getValueString("fromUserId"), row.getValueString("toUserId"), row.getValueString("message"), row.getValueBoolean("recipientCouldRead"), row.getValueInstant("sentAt"), + name(names, row.getValueString("fromUserId")), name(names, row.getValueString("toUserId")))).toList()); } /******************************************************************************* ** Project AccessRequest public fields. *******************************************************************************/ - static CollaborationWorkSchemas.CollaborationAccessRequestV1 accessRequest(com.kingsrook.qqq.backend.core.model.data.QRecord row) + static CollaborationWorkSchemas.CollaborationAccessRequestV1 accessRequest(QRecord row) throws QException { - return (new CollaborationWorkSchemas.CollaborationAccessRequestV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("requesterUserId"), row.getValueString("message"), row.getValueString("status"), row.getValueInstant("handledAt"), row.getValueString("note"))); + return (accessRequests(List.of(row)).getFirst()); + } + + /******************************************************************************* + ** Project access requests, resolving requester and handler names in one batch. + *******************************************************************************/ + static List accessRequests(List rows) throws QException + { + Map names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("requesterUserId"), row.getValueString("handledByUserId"))).toArray(String[]::new)); + return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationAccessRequestV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), + row.getValueString("requesterUserId"), row.getValueString("message"), row.getValueString("status"), row.getValueInstant("handledAt"), row.getValueString("note"), + name(names, row.getValueString("requesterUserId")), name(names, row.getValueString("handledByUserId")))).toList()); } /******************************************************************************* ** Project Task public fields. *******************************************************************************/ - static CollaborationWorkSchemas.CollaborationTaskV1 task(com.kingsrook.qqq.backend.core.model.data.QRecord row) + static CollaborationWorkSchemas.CollaborationTaskV1 task(QRecord row) throws QException { - return (new CollaborationWorkSchemas.CollaborationTaskV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("title"), row.getValueString("notes"), row.getValueString("assigneeUserId"), row.getValueInstant("dueAt"), row.getValueInstant("remindAt"), row.getValueString("status"), row.getValueString("createdByUserId"), row.getValueInstant("completedAt"), row.getValueString("completedByUserId"))); + return (tasks(List.of(row)).getFirst()); + } + + /******************************************************************************* + ** Project tasks, resolving assignee, creator and completer names in one batch. + *******************************************************************************/ + static List tasks(List rows) throws QException + { + Map names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("assigneeUserId"), row.getValueString("createdByUserId"), + row.getValueString("completedByUserId"))).toArray(String[]::new)); + return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationTaskV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), + row.getValueString("title"), row.getValueString("notes"), row.getValueString("assigneeUserId"), row.getValueInstant("dueAt"), row.getValueInstant("remindAt"), row.getValueString("status"), + row.getValueString("createdByUserId"), row.getValueInstant("completedAt"), row.getValueString("completedByUserId"), + name(names, row.getValueString("assigneeUserId")), name(names, row.getValueString("createdByUserId")), name(names, row.getValueString("completedByUserId")))).toList()); } /******************************************************************************* ** Project Assignment public fields. *******************************************************************************/ - static CollaborationWorkSchemas.CollaborationAssignmentV1 assignment(com.kingsrook.qqq.backend.core.model.data.QRecord row) + static CollaborationWorkSchemas.CollaborationAssignmentV1 assignment(QRecord row) throws QException { - return (new CollaborationWorkSchemas.CollaborationAssignmentV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("ownerUserId"), row.getValueString("assigneeUserId"))); + Map 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")))); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationWorkSchemas.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationWorkSchemas.java index 318d469..041f80c 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationWorkSchemas.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CollaborationWorkSchemas.java @@ -95,7 +95,7 @@ public final class CollaborationWorkSchemas /******************************************************************************* ** Versioned Share body. *******************************************************************************/ - public record CollaborationShareV1(Integer id, String tableName, String recordId, String fromUserId, String toUserId, String message, Boolean recipientCouldRead, Instant sentAt) implements ToSchema + public record CollaborationShareV1(Integer id, String tableName, String recordId, String fromUserId, String toUserId, String message, Boolean recipientCouldRead, Instant sentAt, String fromDisplayName, String toDisplayName) implements ToSchema { /******************************************************************************* ** Explicit schema for generated clients. @@ -111,7 +111,9 @@ public final class CollaborationWorkSchemas Map.entry("toUserId", new Schema().withType(Type.STRING).withDescription("toUserId.").withNullable(true)), Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)), Map.entry("recipientCouldRead", new Schema().withType(Type.BOOLEAN).withDescription("recipientCouldRead.").withNullable(true)), - Map.entry("sentAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("sentAt.").withNullable(true)))))); + Map.entry("sentAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("sentAt.").withNullable(true)), + Map.entry("fromDisplayName", new Schema().withType(Type.STRING).withDescription("Sender display name.").withNullable(true)), + Map.entry("toDisplayName", new Schema().withType(Type.STRING).withDescription("Recipient display name.").withNullable(true)))))); } } @@ -151,7 +153,7 @@ public final class CollaborationWorkSchemas /******************************************************************************* ** Versioned AccessRequest body. *******************************************************************************/ - public record CollaborationAccessRequestV1(Integer id, String tableName, String recordId, String requesterUserId, String message, String status, Instant handledAt, String note) implements ToSchema + public record CollaborationAccessRequestV1(Integer id, String tableName, String recordId, String requesterUserId, String message, String status, Instant handledAt, String note, String requesterDisplayName, String handledByDisplayName) implements ToSchema { /******************************************************************************* ** Explicit schema for generated clients. @@ -167,7 +169,9 @@ public final class CollaborationWorkSchemas Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)), Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true)), Map.entry("handledAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("handledAt.").withNullable(true)), - Map.entry("note", new Schema().withType(Type.STRING).withDescription("note.").withNullable(true)))))); + Map.entry("note", new Schema().withType(Type.STRING).withDescription("note.").withNullable(true)), + Map.entry("requesterDisplayName", new Schema().withType(Type.STRING).withDescription("Requester display name.").withNullable(true)), + Map.entry("handledByDisplayName", new Schema().withType(Type.STRING).withDescription("Display name of the person who handled the request.").withNullable(true)))))); } } @@ -210,7 +214,7 @@ public final class CollaborationWorkSchemas /******************************************************************************* ** Versioned Task body. *******************************************************************************/ - public record CollaborationTaskV1(Integer id, String tableName, String recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt, String status, String createdByUserId, Instant completedAt, String completedByUserId) implements ToSchema + public record CollaborationTaskV1(Integer id, String tableName, String recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt, String status, String createdByUserId, Instant completedAt, String completedByUserId, String assigneeDisplayName, String createdByDisplayName, String completedByDisplayName) implements ToSchema { /******************************************************************************* ** Explicit schema for generated clients. @@ -230,7 +234,10 @@ public final class CollaborationWorkSchemas Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true)), Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("createdByUserId.").withNullable(true)), Map.entry("completedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("completedAt.").withNullable(true)), - Map.entry("completedByUserId", new Schema().withType(Type.STRING).withDescription("completedByUserId.").withNullable(true)))))); + Map.entry("completedByUserId", new Schema().withType(Type.STRING).withDescription("completedByUserId.").withNullable(true)), + Map.entry("assigneeDisplayName", new Schema().withType(Type.STRING).withDescription("Assignee display name.").withNullable(true)), + Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Creator display name.").withNullable(true)), + Map.entry("completedByDisplayName", new Schema().withType(Type.STRING).withDescription("Display name of the person who completed the task.").withNullable(true)))))); } } @@ -312,7 +319,7 @@ public final class CollaborationWorkSchemas /******************************************************************************* ** Versioned Assignment body. *******************************************************************************/ - public record CollaborationAssignmentV1(Integer id, String tableName, String recordId, String ownerUserId, String assigneeUserId) implements ToSchema + public record CollaborationAssignmentV1(Integer id, String tableName, String recordId, String ownerUserId, String assigneeUserId, String ownerDisplayName, String assigneeDisplayName) implements ToSchema { /******************************************************************************* ** Explicit schema for generated clients. @@ -325,7 +332,9 @@ public final class CollaborationWorkSchemas Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("tableName.").withNullable(true)), Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("recordId.").withNullable(true)), Map.entry("ownerUserId", new Schema().withType(Type.STRING).withDescription("ownerUserId.").withNullable(true)), - Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true)))))); + Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true)), + Map.entry("ownerDisplayName", new Schema().withType(Type.STRING).withDescription("Owner display name.").withNullable(true)), + Map.entry("assigneeDisplayName", new Schema().withType(Type.STRING).withDescription("Assignee display name.").withNullable(true)))))); } } @@ -357,18 +366,18 @@ public final class CollaborationWorkSchemas schemas.put("CollaborationLinkV1", new CollaborationLinkV1(null, null).toSchema()); schemas.put("CollaborationShareRecipientV1", new CollaborationShareRecipientV1(null, null, null).toSchema()); schemas.put("CollaborationSharePreviewV1", new CollaborationSharePreviewV1(null).toSchema()); - schemas.put("CollaborationShareV1", new CollaborationShareV1(null, null, null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationShareV1", new CollaborationShareV1(null, null, null, null, null, null, null, null, null, null).toSchema()); schemas.put("CollaborationShareListV1", new CollaborationShareListV1(null).toSchema()); schemas.put("CollaborationShareRequestV1", new CollaborationShareRequestV1(null, null).toSchema()); - schemas.put("CollaborationAccessRequestV1", new CollaborationAccessRequestV1(null, null, null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationAccessRequestV1", new CollaborationAccessRequestV1(null, null, null, null, null, null, null, null, null, null).toSchema()); schemas.put("CollaborationAccessRequestCreateRequestV1", new CollaborationAccessRequestCreateRequestV1(null, null, null, null).toSchema()); schemas.put("CollaborationAccessRequestListV1", new CollaborationAccessRequestListV1(null, null).toSchema()); - schemas.put("CollaborationTaskV1", new CollaborationTaskV1(null, null, null, null, null, null, null, null, null, null, null, null).toSchema()); + schemas.put("CollaborationTaskV1", new CollaborationTaskV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema()); schemas.put("CollaborationTaskCreateRequestV1", new CollaborationTaskCreateRequestV1(null, null, null, null, null).toSchema()); schemas.put("CollaborationTaskPatchRequestV1", new CollaborationTaskPatchRequestV1(null, null, null, null, null, null).toSchema()); schemas.put("CollaborationTaskListV1", new CollaborationTaskListV1(null, null).toSchema()); schemas.put("CollaborationAssignmentRequestV1", new CollaborationAssignmentRequestV1(null, null).toSchema()); - schemas.put("CollaborationAssignmentV1", new CollaborationAssignmentV1(null, null, null, null, null).toSchema()); + schemas.put("CollaborationAssignmentV1", new CollaborationAssignmentV1(null, null, null, null, null, null, null).toSchema()); schemas.put("CollaborationMyWorkV1", new CollaborationMyWorkV1(null, null, null, null).toSchema()); return (schemas); } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CommentHistorySpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CommentHistorySpec.java index 568aff1..030d3bc 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/CommentHistorySpec.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CommentHistorySpec.java @@ -69,6 +69,6 @@ public class CommentHistorySpec extends CollaborationJsonSpec protected Object handle(JsonInput input) throws Exception { var page = page(input, new CommentsService().history(id(input))); - return (new CollaborationCommentHistoryV1(page.items().stream().map(CollaborationViews::commentRevision).toList(), page.nextCursor())); + return (new CollaborationCommentHistoryV1(CollaborationViews.commentRevisions(page.items()), page.nextCursor())); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/CreateSharesSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/CreateSharesSpec.java index 11a2b1a..136450d 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/CreateSharesSpec.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/CreateSharesSpec.java @@ -75,8 +75,8 @@ public class CreateSharesSpec extends CollaborationJsonSpec protected Object handle(JsonInput input) throws Exception { var body = body(input, CollaborationWorkSchemas.CollaborationShareRequestV1.class); - return (new CollaborationWorkSchemas.CollaborationShareListV1(new com.kingsrook.qbits.collaboration.sharing.SharingService() - .share(table(input), pk(input), recipients(body.toUserIds()), body.message()).stream().map(CollaborationViews::share).toList())); + return (new CollaborationWorkSchemas.CollaborationShareListV1(CollaborationViews.shares(new com.kingsrook.qbits.collaboration.sharing.SharingService() + .share(table(input), pk(input), recipients(body.toUserIds()), body.message())))); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/GetFollowSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/GetFollowSpec.java index b92dd40..1b636cd 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/GetFollowSpec.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/GetFollowSpec.java @@ -69,6 +69,6 @@ public class GetFollowSpec extends CollaborationJsonSpec protected Object handle(JsonInput input) throws Exception { var state = new FollowService().state(table(input), pk(input)); - return (new CollaborationFollowStateV1(state.isPresent(), state.map(CollaborationViews::follow).orElse(null))); + return (new CollaborationFollowStateV1(state.isPresent(), state.isPresent() ? CollaborationViews.follow(state.get()) : null)); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/GetMyWorkSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/GetMyWorkSpec.java index c745655..64ddd51 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/GetMyWorkSpec.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/GetMyWorkSpec.java @@ -65,7 +65,7 @@ public class GetMyWorkSpec extends CollaborationJsonSpec protected Object handle(JsonInput input) throws Exception { var summary = new com.kingsrook.qbits.collaboration.mywork.MyWorkService().summary(); - return (new CollaborationWorkSchemas.CollaborationMyWorkV1(summary.openTaskCount(), summary.tasks().stream().map(CollaborationViews::task).toList(), + return (new CollaborationWorkSchemas.CollaborationMyWorkV1(summary.openTaskCount(), CollaborationViews.tasks(summary.tasks()), summary.assignedRecordsByTable(), summary.unreadMentionCount())); } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListAttachmentsSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListAttachmentsSpec.java index 5defb05..5712cd4 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/ListAttachmentsSpec.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListAttachmentsSpec.java @@ -69,6 +69,6 @@ public class ListAttachmentsSpec extends CollaborationJsonSpec protected Object handle(JsonInput input) throws Exception { var page = page(input, new AttachmentsService().list(table(input), pk(input))); - return (new CollaborationAttachmentListV1(page.items().stream().map(CollaborationViews::attachment).toList(), page.nextCursor())); + return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), page.nextCursor())); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListFollowersSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListFollowersSpec.java index ab3a306..b87b7b7 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/ListFollowersSpec.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListFollowersSpec.java @@ -69,6 +69,6 @@ public class ListFollowersSpec extends CollaborationJsonSpec protected Object handle(JsonInput input) throws Exception { var page = page(input, new FollowService().followers(table(input), pk(input))); - return (new CollaborationFollowerListV1(page.items().stream().map(CollaborationViews::follow).toList(), page.nextCursor())); + return (new CollaborationFollowerListV1(CollaborationViews.follows(page.items()), page.nextCursor())); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyAccessRequestsSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyAccessRequestsSpec.java index 6143ce6..a8fa525 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyAccessRequestsSpec.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyAccessRequestsSpec.java @@ -66,7 +66,7 @@ public class ListMyAccessRequestsSpec extends CollaborationJsonSpec { var values = new com.kingsrook.qbits.collaboration.sharing.AccessRequestService().mine(); var page = page(input, values); - return (new CollaborationWorkSchemas.CollaborationAccessRequestListV1(page.items().stream().map(CollaborationViews::accessRequest).toList(), page.nextCursor())); + return (new CollaborationWorkSchemas.CollaborationAccessRequestListV1(CollaborationViews.accessRequests(page.items()), page.nextCursor())); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyTasksSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyTasksSpec.java index ae297fd..fb6b55f 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyTasksSpec.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListMyTasksSpec.java @@ -65,7 +65,7 @@ public class ListMyTasksSpec extends CollaborationJsonSpec protected Object handle(JsonInput input) throws Exception { var page = new com.kingsrook.qbits.collaboration.tasks.TasksService().mine(input.getQueryParam("status"), limit(input), input.getQueryParam("cursor")); - return (new CollaborationWorkSchemas.CollaborationTaskListV1(page.tasks().stream().map(CollaborationViews::task).toList(), page.nextCursor())); + return (new CollaborationWorkSchemas.CollaborationTaskListV1(CollaborationViews.tasks(page.tasks()), page.nextCursor())); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/api/ListTasksSpec.java b/src/main/java/com/kingsrook/qbits/collaboration/api/ListTasksSpec.java index 827e9c2..c63f2e1 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/api/ListTasksSpec.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/api/ListTasksSpec.java @@ -70,7 +70,7 @@ public class ListTasksSpec extends CollaborationJsonSpec throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("includeDone must be true or false")); } var page = page(input, new com.kingsrook.qbits.collaboration.tasks.TasksService().list(table(input), pk(input), Boolean.valueOf(includeDone))); - return (new CollaborationWorkSchemas.CollaborationTaskListV1(page.items().stream().map(CollaborationViews::task).toList(), page.nextCursor())); + return (new CollaborationWorkSchemas.CollaborationTaskListV1(CollaborationViews.tasks(page.items()), page.nextCursor())); } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java index ef03d4c..4968f3f 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java @@ -132,7 +132,7 @@ public final class CollaborationTables enumSource(FEATURE, "ATTACHMENTS", "TAGS", "CUSTOM_FIELDS", "COMMENTS", "PRESENCE", "SHARING", "FAVORITES", "FOLLOW", "TIMELINE", "RECENTS", "TASKS", "ASSIGNMENT"), enumSource(PERMISSION_MODE, "EDIT_OR_GRANTED", "GRANTED_ONLY"), - QPossibleValueSource.newForTable(FEATURE_SETTING), QPossibleValueSource.newForTable(USER))); + QPossibleValueSource.newForTable(FEATURE_SETTING), QPossibleValueSource.newForTable(USER).withOverrideIdField("userId"))); } @@ -239,7 +239,7 @@ public final class CollaborationTables .withRecordSecurityLock(CollaborationSecurity.ownerLock("userId")) .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE)) .withRecordLabelFormatAndFields("%s", "label") - .withField(string("userId", "User ID", 255).withIsRequired(true)) + .withField(user("userId", "User ID").withIsRequired(true)) .withField(string("targetType", "Target Type", 20).withIsRequired(true).withPossibleValueSourceName(TARGET_TYPE)) .withField(string("targetName", "Target Name", 255).withIsRequired(true)) .withField(string("recordId", "Record ID", 255)) @@ -251,6 +251,15 @@ public final class CollaborationTables + /******************************************************************************* + ** A user-id string shown by display name wherever the table is browsed. + *******************************************************************************/ + private static QFieldMetaData user(String name, String label) + { + return (string(name, label, 255).withPossibleValueSourceName(USER)); + } + + /******************************************************************************* ** A bounded string field. *******************************************************************************/ @@ -280,7 +289,7 @@ public final class CollaborationTables .withField(string("storageKey", "Storage Key", 1024).withIsRequired(true)) .withField(string("thumbnailKey", "Thumbnail Key", 1024)) .withField(new QFieldMetaData("description", QFieldType.TEXT).withLabel("Description")) - .withField(string("createdByUserId", "Created By", 255).withIsRequired(true)) + .withField(user("createdByUserId", "Created By").withIsRequired(true)) .withField(new QFieldMetaData("parentDeleted", QFieldType.BOOLEAN).withLabel("Parent Deleted").withIsRequired(true).withDefaultValue(false)) .withVirtualField(new QVirtualFieldMetaData("previewUrl", QFieldType.STRING).withLabel("Preview").withIsEditable(false).withIsQuerySelectable(true) .withFieldAdornment(AdornmentType.FilePreviewValues.newFieldAdornment() @@ -322,28 +331,28 @@ public final class CollaborationTables .withField(new QFieldMetaData("body", QFieldType.TEXT).withMaxLength(20000)) // TODO: add .withDependsOn("tableName") when QQQ core ships contingent dependsOn (docs/CONTINGENT-POSSIBLE-VALUES.md) .withField(string("fieldName", "Field", 128).withPossibleValueSourceName(RECORD_FIELD_NAME)) - .withField(string("authorUserId", "Author", 255).withIsRequired(true)) + .withField(user("authorUserId", "Author").withIsRequired(true)) .withField(new QFieldMetaData("lastActivityAt", QFieldType.DATE_TIME).withIsRequired(true)) .withField(new QFieldMetaData("editedAt", QFieldType.DATE_TIME)) .withField(new QFieldMetaData("resolvedAt", QFieldType.DATE_TIME)) - .withField(string("resolvedByUserId", "Resolved By", 255)) + .withField(user("resolvedByUserId", "Resolved By")) .withField(new QFieldMetaData("deletedAt", QFieldType.DATE_TIME)) - .withField(string("deletedByUserId", "Deleted By", 255)) + .withField(user("deletedByUserId", "Deleted By")) .withRecordLabelFormatAndFields("Comment %s", "id"); QTableMetaData revisions = referenced(config, COMMENT_REVISION, "Comment History", "history") .withField(new QFieldMetaData("commentId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(COMMENT)) .withField(new QFieldMetaData("body", QFieldType.TEXT)) .withField(new QFieldMetaData("editedAt", QFieldType.DATE_TIME).withIsRequired(true)) - .withField(string("editedByUserId", "Edited By", 255)); + .withField(user("editedByUserId", "Edited By")); QTableMetaData mentions = referenced(config, MENTION, "Mentions", "alternate_email") .withField(new QFieldMetaData("commentId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(COMMENT)) - .withField(string("mentionedUserId", "Mentioned User", 255).withIsRequired(true)) + .withField(user("mentionedUserId", "Mentioned User").withIsRequired(true)) .withField(new QFieldMetaData("visible", QFieldType.BOOLEAN).withIsRequired(true)) .withField(new QFieldMetaData("readAt", QFieldType.DATE_TIME)) .withField(new QFieldMetaData("notifiedAt", QFieldType.DATE_TIME)) .withUniqueKey(new UniqueKey("commentId", "mentionedUserId")); QTableMetaData follows = referenced(config, FOLLOW, "Followers", "notifications") - .withField(string("userId", "User", 255).withIsRequired(true)) + .withField(user("userId", "User").withIsRequired(true)) .withField(string("reason", "Reason", 32).withIsRequired(true)) .withField(new QFieldMetaData("notifyOnChange", QFieldType.BOOLEAN).withIsRequired(true)) .withField(new QFieldMetaData("notifyOnComment", QFieldType.BOOLEAN).withIsRequired(true)) @@ -353,13 +362,13 @@ public final class CollaborationTables .withoutCapabilities(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE) .withField(new QFieldMetaData("at", QFieldType.DATE_TIME).withIsRequired(true)) - .withField(string("actorUserId", "Actor", 255)) + .withField(user("actorUserId", "Actor")) .withField(string("type", "Type", 64).withIsRequired(true)) .withField(new QFieldMetaData("summary", QFieldType.TEXT)) .withField(string("refTableName", "Reference Table", 128)) .withField(string("refId", "Reference ID", 64)); QTableMetaData digests = referenced(config, FOLLOW_DIGEST, "Follow Digests", "notifications") - .withField(string("userId", "Recipient", 255).withIsRequired(true)) + .withField(user("userId", "Recipient").withIsRequired(true)) .withField(new QFieldMetaData("dueAt", QFieldType.DATE_TIME).withIsRequired(true)) .withField(new QFieldMetaData("changes", QFieldType.TEXT)) .withUniqueKey(new UniqueKey("tableName", "recordId", "userId")); @@ -411,7 +420,7 @@ public final class CollaborationTables .withField(string("tableName", "Table", 128)) .withField(new QFieldMetaData("approved", QFieldType.BOOLEAN).withIsRequired(true)) .withField(new QFieldMetaData("description", QFieldType.TEXT)) - .withField(string("createdByUserId", "Created By", 255).withIsRequired(true)) + .withField(user("createdByUserId", "Created By").withIsRequired(true)) .withField(new QFieldMetaData("usageCount", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(0)) .withField(new QFieldMetaData("mergedIntoTagId", QFieldType.INTEGER).withPossibleValueSourceName(TAG)) .withUniqueKey(new UniqueKey("tableName", "name")) @@ -421,7 +430,7 @@ public final class CollaborationTables .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED) .withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.security.CollaborationRowSecurity.class))) .withField(new QFieldMetaData("tagId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(TAG)) - .withField(string("createdByUserId", "Created By", 255).withIsRequired(true)) + .withField(user("createdByUserId", "Created By").withIsRequired(true)) .withUniqueKey(new UniqueKey("tableName", "recordId", "tagId")) .withSection(new QFieldSection("details", new QIcon("label"), Tier.T1, List.of("id", "tagId", "createdByUserId")).withLabel("Details")); for(QTableMetaData table : List.of(tags, links)) @@ -437,8 +446,8 @@ public final class CollaborationTables private static List workTables(CollaborationQBitConfig config) { QTableMetaData shares = referenced(config, SHARE, "Shares", "share") - .withField(string("fromUserId", "From", 255).withIsRequired(true)) - .withField(string("toUserId", "To", 255).withIsRequired(true)) + .withField(user("fromUserId", "From").withIsRequired(true)) + .withField(user("toUserId", "To").withIsRequired(true)) .withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000)) .withField(new QFieldMetaData("recipientCouldRead", QFieldType.BOOLEAN)) .withField(new QFieldMetaData("sentAt", QFieldType.DATE_TIME).withIsRequired(true)) @@ -451,29 +460,29 @@ public final class CollaborationTables .withRecordLabelFormatAndFields("Access request %s", "id") .withField(string("tableName", "Table", 128).withIsRequired(true)) .withField(string("recordId", "Record ID", 64).withIsRequired(true)) - .withField(string("requesterUserId", "Requester", 255).withIsRequired(true)) + .withField(user("requesterUserId", "Requester").withIsRequired(true)) .withField(new QFieldMetaData("shareId", QFieldType.INTEGER)) .withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000)) .withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_access_request_status")) - .withField(string("handledByUserId", "Handled By", 255)) + .withField(user("handledByUserId", "Handled By")) .withField(new QFieldMetaData("handledAt", QFieldType.DATE_TIME)) .withField(new QFieldMetaData("note", QFieldType.TEXT).withMaxLength(2000)); QTableMetaData tasks = referenced(config, TASK, "Tasks", "task_alt") .withRecordLabelFormatAndFields("%s", "title").withSearchFields(List.of("title")) .withField(string("title", "Title", 200).withIsRequired(true)) .withField(new QFieldMetaData("notes", QFieldType.TEXT)) - .withField(string("assigneeUserId", "Assignee", 255)) + .withField(user("assigneeUserId", "Assignee")) .withField(new QFieldMetaData("dueAt", QFieldType.DATE_TIME)) .withField(new QFieldMetaData("remindAt", QFieldType.DATE_TIME)) .withField(new QFieldMetaData("remindedAt", QFieldType.DATE_TIME)) .withField(new QFieldMetaData("overdueNotifiedAt", QFieldType.DATE_TIME)) .withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_task_status")) .withField(new QFieldMetaData("completedAt", QFieldType.DATE_TIME)) - .withField(string("completedByUserId", "Completed By", 255)) - .withField(string("createdByUserId", "Created By", 255).withIsRequired(true)); + .withField(user("completedByUserId", "Completed By")) + .withField(user("createdByUserId", "Created By").withIsRequired(true)); QTableMetaData assignments = referenced(config, RECORD_ASSIGNMENT, "Record Owners", "assignment_ind") - .withField(string("ownerUserId", "Owner", 255)) - .withField(string("assigneeUserId", "Assignee", 255)) + .withField(user("ownerUserId", "Owner")) + .withField(user("assigneeUserId", "Assignee")) .withUniqueKey(new UniqueKey("tableName", "recordId")); List tables = List.of(shares, requests, tasks, assignments); for(QTableMetaData table : tables) @@ -519,7 +528,7 @@ public final class CollaborationTables .withField(string("storage", "Storage", 16).withIsEditable(false)) .withField(new QFieldMetaData("statusMessage", QFieldType.TEXT).withIsEditable(false)) .withField(new QFieldMetaData("indexDdl", QFieldType.TEXT).withLabel("Index DDL").withIsEditable(false)) - .withField(string("createdByUserId", "Created by", 255).withIsEditable(false)) + .withField(user("createdByUserId", "Created by").withIsEditable(false)) .withUniqueKey(new UniqueKey("tableName", "name")); QCodeReference customizer = new QCodeReference(com.kingsrook.qbits.collaboration.customfields.FieldDefinitionCustomizer.class); definitions.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, customizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, customizer) diff --git a/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java b/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java index ed93b95..6168f03 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java @@ -161,6 +161,37 @@ public class PeopleService return (rows.isEmpty() || rows.getFirst().getValueString("displayName") == null ? userId : rows.getFirst().getValueString("displayName")); } + /******************************************************************************* + ** Resolve many display names with one directory query. Unknown ids map to + ** themselves, so a caller always has something readable to show. + *******************************************************************************/ + public java.util.Map displayNames(java.util.Collection userIds) throws QException + { + java.util.Map names = new java.util.LinkedHashMap<>(); + Set ids = new java.util.LinkedHashSet<>(); + for(String id : userIds) + { + if(id != null && !id.isBlank()) + { + ids.add(id); + } + } + if(ids.isEmpty()) + { + return (names); + } + for(QRecord row : CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(ids)))) + { + String name = row.getValueString("displayName"); + if(name != null && !name.isBlank()) + { + names.put(row.getValueString("userId"), name); + } + } + ids.forEach(id -> names.putIfAbsent(id, id)); + return (names); + } + /******************************************************************************* ** Look up one active person with the same signed-in boundary as search. *******************************************************************************/ diff --git a/src/main/resources/openapi/qqq-v1/collaboration.yaml b/src/main/resources/openapi/qqq-v1/collaboration.yaml index 9cd4fda..fa43687 100644 --- a/src/main/resources/openapi/qqq-v1/collaboration.yaml +++ b/src/main/resources/openapi/qqq-v1/collaboration.yaml @@ -39,6 +39,10 @@ components: format: "date-time" nullable: true type: "string" + handledByDisplayName: + description: "Display name of the person who handled the request." + nullable: true + type: "string" id: description: "id." nullable: true @@ -55,6 +59,10 @@ components: description: "recordId." nullable: true type: "string" + requesterDisplayName: + description: "Requester display name." + nullable: true + type: "string" requesterUserId: description: "requesterUserId." nullable: true @@ -81,6 +89,10 @@ components: type: "object" CollaborationAssignmentV1: properties: + assigneeDisplayName: + description: "Assignee display name." + nullable: true + type: "string" assigneeUserId: description: "assigneeUserId." nullable: true @@ -89,6 +101,10 @@ components: description: "id." nullable: true type: "integer" + ownerDisplayName: + description: "Owner display name." + nullable: true + type: "string" ownerUserId: description: "ownerUserId." nullable: true @@ -159,6 +175,10 @@ components: format: "date-time" nullable: true type: "string" + createdByDisplayName: + description: "Uploader display name." + nullable: true + type: "string" createdByUserId: description: "Uploader identity." nullable: true @@ -258,6 +278,10 @@ components: format: "date-time" nullable: true type: "string" + editedByDisplayName: + description: "Editor display name." + nullable: true + type: "string" editedByUserId: description: "Editor identity." nullable: true @@ -494,6 +518,10 @@ components: - "SHARED" nullable: true type: "string" + userDisplayName: + description: "Follower display name." + nullable: true + type: "string" userId: description: "Follower identity." nullable: true @@ -699,6 +727,10 @@ components: type: "object" CollaborationShareV1: properties: + fromDisplayName: + description: "Sender display name." + nullable: true + type: "string" fromUserId: description: "fromUserId." nullable: true @@ -728,6 +760,10 @@ components: description: "tableName." nullable: true type: "string" + toDisplayName: + description: "Recipient display name." + nullable: true + type: "string" toUserId: description: "toUserId." nullable: true @@ -904,6 +940,10 @@ components: type: "object" CollaborationTaskV1: properties: + assigneeDisplayName: + description: "Assignee display name." + nullable: true + type: "string" assigneeUserId: description: "assigneeUserId." nullable: true @@ -913,10 +953,18 @@ components: format: "date-time" nullable: true type: "string" + completedByDisplayName: + description: "Display name of the person who completed the task." + nullable: true + type: "string" completedByUserId: description: "completedByUserId." nullable: true type: "string" + createdByDisplayName: + description: "Creator display name." + nullable: true + type: "string" createdByUserId: description: "createdByUserId." nullable: true diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java index 034649b..ce45724 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java @@ -32,6 +32,7 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import com.fasterxml.jackson.databind.JsonNode; @@ -40,6 +41,7 @@ import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage; import com.kingsrook.qbits.objectstorage.api.PresignGet; import com.kingsrook.qbits.objectstorage.api.PutRequest; import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay; +import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface; import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; @@ -75,7 +77,7 @@ class CollaborationApiHttpTest extends PersonalServiceFixture MemoryStorage.objects.clear(); MemoryStorage.lastGet = null; MemoryStorage.fail = false; - config.withAttachmentStorage(new QCodeReference(MemoryStorage.class)); + config.withAttachmentStorage(new QCodeReference(MemoryStorage.class)).withUserSessionProvider(new QCodeReference(CarolSessions.class)); instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK) .withCustomizer(new QCodeReference(TestSessions.class))); int port; @@ -167,6 +169,49 @@ class CollaborationApiHttpTest extends PersonalServiceFixture assertError(400, "POST", "/records/document/1/tasks", "{\"title\":5}", "alice"); } + /******************************************************************************* + ** Every payload that carries a user id also carries a display name, resolved in + ** one batch per response; ids with no directory entry fall back to themselves. + *******************************************************************************/ + @Test + void payloadsCarryDisplayNames() throws Exception + { + assertThat(json(request("GET", "/people/carol", null, "carol"), 200).get("displayName").asText()).isEqualTo("Carol Diaz"); + + var task = ok("POST", "/records/document/1/tasks", "{\"title\":\"Review\",\"assigneeUserId\":\"carol\"}"); + assertThat(task.get("assigneeDisplayName").asText()).isEqualTo("Carol Diaz"); + assertThat(task.get("createdByDisplayName").asText()).isEqualTo("Alice Morgan"); + assertThat(task.get("completedByDisplayName").isNull()).isTrue(); + assertThat(ok("GET", "/records/document/1/tasks", null).at("/items/0/assigneeDisplayName").asText()).isEqualTo("Carol Diaz"); + assertThat(ok("GET", "/my-work", null).at("/tasks").size()).isZero(); + var done = ok("PATCH", "/tasks/" + task.get("id").asInt(), "{\"status\":\"DONE\"}"); + assertThat(done.get("completedByDisplayName").asText()).isEqualTo("Alice Morgan"); + assertThat(json(request("GET", "/tasks/mine?status=DONE", null, "carol"), 200).at("/items/0/createdByDisplayName").asText()).isEqualTo("Alice Morgan"); + + JsonNode upload = json(upload("alice", "report.txt", "hello", "/records/document/1/attachments"), 200); + assertThat(upload.get("createdByDisplayName").asText()).isEqualTo("Alice Morgan"); + assertThat(ok("GET", "/records/document/1/attachments", null).at("/items/0/createdByDisplayName").asText()).isEqualTo("Alice Morgan"); + + JsonNode comment = ok("POST", "/records/document/1/comments", "{\"body\":\"First\"}"); + assertThat(comment.get("authorDisplayName").asText()).isEqualTo("Alice Morgan"); + ok("PATCH", "/comments/" + comment.get("id").asInt(), "{\"body\":\"Edited\"}"); + assertThat(ok("GET", "/comments/" + comment.get("id").asInt() + "/history", null).at("/items/0/editedByDisplayName").asText()).isEqualTo("Alice Morgan"); + assertThat(ok("GET", "/records/document/1/followers", null).findValuesAsText("userDisplayName")).contains("Alice Morgan"); + + JsonNode shared = ok("POST", "/records/document/1/shares", "{\"toUserIds\":[\"carol\"],\"message\":\"Look\"}"); + assertThat(shared.at("/items/0/fromDisplayName").asText()).isEqualTo("Alice Morgan"); + assertThat(shared.at("/items/0/toDisplayName").asText()).isEqualTo("Carol Diaz"); + + JsonNode request = ok("POST", "/access-requests", "{\"tableName\":\"document\",\"recordId\":\"999\",\"message\":\"Please\"}"); + assertThat(request.get("requesterDisplayName").asText()).isEqualTo("Alice Morgan"); + assertThat(request.get("handledByDisplayName").isNull()).isTrue(); + assertThat(ok("GET", "/access-requests/mine", null).at("/items/0/requesterDisplayName").asText()).isEqualTo("Alice Morgan"); + + JsonNode assignment = ok("PUT", "/records/document/1/assignment", "{\"ownerUserId\":\"alice\",\"assigneeUserId\":\"carol\"}"); + assertThat(assignment.get("ownerDisplayName").asText()).isEqualTo("Alice Morgan"); + assertThat(assignment.get("assigneeDisplayName").asText()).isEqualTo("Carol Diaz"); + } + /******************************************************************************* ** Presence endpoints share parent checks and return typed merged snapshots. *******************************************************************************/ @@ -347,7 +392,7 @@ class CollaborationApiHttpTest extends PersonalServiceFixture assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isFalse(); JsonNode people = ok("GET", "/people?q=alice", null); assertThat(people.at("/items/0/userId").asText()).isEqualTo("alice"); - assertThat(ok("GET", "/people/alice", null).get("displayName").asText()).isEqualTo("alice"); + assertThat(ok("GET", "/people/alice", null).get("displayName").asText()).isEqualTo("Alice Morgan"); JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=alice", null).at("/items/0"); assertThat(candidate.at("/person/userId").asText()).isEqualTo("alice"); assertThat(candidate.get("canRead").asText()).isEqualTo("unknown"); @@ -484,15 +529,33 @@ class CollaborationApiHttpTest extends PersonalServiceFixture public void customizeSession(QInstance qInstance, QSession session, Map context) { String caller = session.getUuid().split("-")[0]; - session.withUser(new QUser().withIdReference(caller).withFullName(caller)); + session.withUser(new QUser().withIdReference(caller).withFullName(switch(caller) + { + case "alice" -> "Alice Morgan"; + case "carol" -> "Carol Diaz"; + default -> caller; + })); session.withPermissions("document.read").withSecurityKeyValue("tenant", caller.equals("outsider") ? "b" : "a"); - if(caller.equals("alice")) + if(caller.equals("alice") || caller.equals("carol")) { session.withPermissions("document.write"); } } } + /******************************************************************************* + ** Carol is the one other person who can open the shared documents. + *******************************************************************************/ + public static class CarolSessions implements UserSessionProviderInterface + { + @Override + public Optional sessionForUser(String userId) + { + return (Optional.ofNullable("carol".equals(userId) ? new QSession().withUser(new QUser().withIdReference("carol")).withPermissions("document.read", "document.write") + .withSecurityKeyValue("tenant", "a") : null)); + } + } + /******************************************************************************* ** Public no-arg adapter loaded through host configuration, with in-memory bytes. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java index cb60be5..8eb8b8c 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java @@ -253,6 +253,26 @@ class CollaborationTest + /******************************************************************************* + ** Display names resolve in one query; unknown, blank and null ids stay readable. + *******************************************************************************/ + @Test + void displayNamesResolveInBatches() throws Exception + { + seed(CollaborationTables.USER, new QRecord().withValue("userId", "ann").withValue("displayName", "Ann Lee").withValue("active", true), + new QRecord().withValue("userId", "nameless").withValue("displayName", " ").withValue("active", true)); + var names = new com.kingsrook.qbits.collaboration.people.PeopleService().displayNames(java.util.Arrays.asList("ann", "nameless", "ghost", null, "", "ann")); + assertThat(names).containsExactly(java.util.Map.entry("ann", "Ann Lee"), java.util.Map.entry("nameless", "nameless"), java.util.Map.entry("ghost", "ghost")); + assertThat(new com.kingsrook.qbits.collaboration.people.PeopleService().displayNames(List.of())).isEmpty(); + assertThat(instance.getTable(CollaborationTables.TASK).getField("assigneeUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER); + assertThat(instance.getTable(CollaborationTables.COMMENT).getField("authorUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER); + assertThat(instance.getTable(CollaborationTables.ATTACHMENT).getField("createdByUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER); + assertThat(instance.getTable(CollaborationTables.USER).getField("userId").getPossibleValueSourceName()).isNull(); + assertThat(instance.getPossibleValueSource(CollaborationTables.USER).getOverrideIdField()).isEqualTo("userId"); + } + + + /******************************************************************************* ** Tags and custom fields list real rows with table labels, All tables for global ones. *******************************************************************************/ -- 2.54.0 From 03669391d0f9dda54014a72a9a81a5f0e5bfdeaa Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:23:44 -0500 Subject: [PATCH 10/12] 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: and the test asserts the absolute URL. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ .../qbits/collaboration/people/PeopleService.java | 10 ++++++---- .../collaboration/CollaborationApiHttpTest.java | 15 ++++++++++----- .../collaboration/ConversationServicesTest.java | 10 +++++++--- .../devserver/CollaborationDevServer.java | 14 ++++++++++++-- .../devserver/CollaborationDevServerTest.java | 13 +++++++------ 6 files changed, 44 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c422b67..57b7203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Fixed +- 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. diff --git a/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java b/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java index 6168f03..4bef801 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java @@ -109,10 +109,11 @@ public class PeopleService /******************************************************************************* ** Rank participants before limiting, with bounded backend queries for both groups. + ** The caller never appears in the results: nobody shares with or mentions themself. *******************************************************************************/ public List search(String q, Integer limit, Set preferredUserIds) throws QException { - PersonalRecords.currentUserId(); + String me = PersonalRecords.currentUserId(); if(limit == null || limit < 1 || limit > 100) { throw (new QBadRequestException("Limit must be between 1 and 100")); @@ -120,12 +121,12 @@ public class PeopleService List rows = new ArrayList<>(); if(!preferredUserIds.isEmpty()) { - rows.addAll(CollaborationRecords.query(CollaborationTables.USER, searchFilter(q, limit) + rows.addAll(CollaborationRecords.query(CollaborationTables.USER, searchFilter(q, limit, me) .withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferredUserIds)))); } if(rows.size() < limit) { - QQueryFilter other = searchFilter(q, limit - rows.size()); + QQueryFilter other = searchFilter(q, limit - rows.size(), me); if(!preferredUserIds.isEmpty()) { other.withCriteria("userId", QCriteriaOperator.NOT_IN, new ArrayList<>(preferredUserIds)); @@ -139,9 +140,10 @@ public class PeopleService /******************************************************************************* ** Keep active and group restrictions outside the display-name/email OR. *******************************************************************************/ - private QQueryFilter searchFilter(String q, Integer limit) + private QQueryFilter searchFilter(String q, Integer limit, String excludedUserId) { QQueryFilter filter = new QQueryFilter().withCriteria("active", QCriteriaOperator.EQUALS, true) + .withCriteria("userId", QCriteriaOperator.NOT_EQUALS, excludedUserId) .withOrderBy(new QFilterOrderBy("displayName")).withOrderBy(new QFilterOrderBy("userId")).withLimit(limit); if(q != null && !q.isBlank()) { diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java index ce45724..f364cfd 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationApiHttpTest.java @@ -390,12 +390,17 @@ class CollaborationApiHttpTest extends PersonalServiceFixture assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isTrue(); ok("POST", "/comments/" + id + "/reopen", null); assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isFalse(); - JsonNode people = ok("GET", "/people?q=alice", null); - assertThat(people.at("/items/0/userId").asText()).isEqualTo("alice"); + assertThat(json(request("GET", "/people/carol", null, "carol"), 200).get("displayName").asText()).isEqualTo("Carol Diaz"); + JsonNode people = ok("GET", "/people?q=carol", null); + assertThat(people.at("/items/0/userId").asText()).isEqualTo("carol"); + assertThat(ok("GET", "/people?q=alice", null).get("items")).as("the caller is not offered to themself").isEmpty(); + assertThat(ok("GET", "/people", null).findValuesAsText("userId")).doesNotContain("alice").contains("carol"); assertThat(ok("GET", "/people/alice", null).get("displayName").asText()).isEqualTo("Alice Morgan"); - JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=alice", null).at("/items/0"); - assertThat(candidate.at("/person/userId").asText()).isEqualTo("alice"); - assertThat(candidate.get("canRead").asText()).isEqualTo("unknown"); + assertThat(ok("GET", "/mentions/candidates?table=document&pk=1&q=alice", null).get("items")).isEmpty(); + assertThat(ok("GET", "/mentions/candidates?table=document&pk=1", null).findValuesAsText("userId")).doesNotContain("alice"); + JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=carol", null).at("/items/0"); + assertThat(candidate.at("/person/userId").asText()).isEqualTo("carol"); + assertThat(candidate.get("canRead").asText()).isEqualTo("true"); assertThat(ok("GET", "/records/document/1/follow", null).at("/follow/reason").asText()).isEqualTo("COMMENTED"); JsonNode state = ok("PUT", "/records/document/1/follow", "{\"notifyOnChange\":false,\"notifyOnComment\":true}"); assertThat(state.at("/follow/notifyOnChange").asBoolean()).isFalse(); diff --git a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java index 02f695e..ac99d27 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java @@ -347,11 +347,15 @@ public class ConversationServicesTest extends PersonalServiceFixture new PeopleService(Clock.fixed(at, ZoneOffset.UTC)).touch(); QContext.getQSession().getUser().setFullName("Changed Alice"); new PeopleService(Clock.fixed(at.plusSeconds(599), ZoneOffset.UTC)).touch(); - assertThat(new PeopleService().search("Alice", 5)).singleElement().satisfies(p -> assertThat(p.displayName()).isEqualTo("Alice Example")); - new PeopleService(Clock.fixed(at.plusSeconds(600), ZoneOffset.UTC)).touch(); - assertThat(new PeopleService().search("changed", 5)).hasSize(1); + assertThat(new PeopleService().search("Alice", 5)).as("the caller never finds themself").isEmpty(); login("bob"); new PeopleService().touch(); + assertThat(new PeopleService().search("Alice", 5)).singleElement().satisfies(p -> assertThat(p.displayName()).isEqualTo("Alice Example")); + login("alice"); + QContext.getQSession().getUser().setFullName("Changed Alice"); + new PeopleService(Clock.fixed(at.plusSeconds(600), ZoneOffset.UTC)).touch(); + login("bob"); + assertThat(new PeopleService().search("changed", 5)).hasSize(1); follows.follow("document", 1, null, null); login("eve"); new PeopleService().touch(); diff --git a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java index 4382908..4f874c9 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java @@ -155,7 +155,7 @@ public class CollaborationDevServer extends AbstractQQQApplication String user = System.getProperty("collab.dev.user", "alice"); session(user); int port = Integer.parseInt(System.getProperty("collab.dev.port", "8098")); - QInstance instance = buildInstance(); + QInstance instance = buildInstance(port); seed(instance); QApplicationJavalinServer server = server(instance, port, true); server.start(); @@ -194,6 +194,16 @@ public class CollaborationDevServer extends AbstractQQQApplication ** Normal host metadata, plus the same collaboration installation as a real app. *******************************************************************************/ static QInstance buildInstance() throws Exception + { + return (buildInstance(Integer.parseInt(System.getProperty("collab.dev.port", "8098")))); + } + + + + /******************************************************************************* + ** The port is part of the configuration: shared links are absolute URLs to this server. + *******************************************************************************/ + static QInstance buildInstance(int port) throws Exception { QInstance instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData() .withName("mock").withType(QAuthenticationType.MOCK).withCustomizer(new QCodeReference(DemoSessions.class))); @@ -232,7 +242,7 @@ public class CollaborationDevServer extends AbstractQQQApplication new AuditsMetaDataProvider().withRecordIdType(QFieldType.STRING).withDefineRecordScreens(true) .defineAll(instance, "memory", table -> table.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))); new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory") - .withAdminApp(true).withParentAppName("admin").withUiApi(true) + .withAdminApp(true).withParentAppName("admin").withUiApi(true).withFrontendBaseUrl("http://localhost:" + port) .withTableFeatures("person", RecordFeature.values()).withTableFeatures("sales_order", RecordFeature.values()) .withAttachmentStorage(new QCodeReference(MemoryStorage.class)) .withUserSessionProvider(new QCodeReference(DemoSessions.class)) diff --git a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java index c631397..fe08c40 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java @@ -81,7 +81,12 @@ class CollaborationDevServerTest try(HttpClient client = HttpClient.newHttpClient()) { System.setProperty("collab.dev.user", "alice"); - instance = CollaborationDevServer.buildInstance(); + int port; + try(ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + instance = CollaborationDevServer.buildInstance(port); CollaborationDevServer.seed(instance); new QInstanceValidator().validate(instance); assertThat(rows("person")).hasSize(25); @@ -142,11 +147,6 @@ class CollaborationDevServerTest } assertThatThrownBy(() -> CollaborationDevServer.session("unknown")).isInstanceOf(IllegalArgumentException.class); System.setProperty("collab.dev.user", "alice"); - int port; - try(ServerSocket socket = new ServerSocket(0)) - { - port = socket.getLocalPort(); - } System.setProperty("collab.dev.port", Integer.toString(port)); server = CollaborationDevServer.server(instance, port, false); server.start(); @@ -156,6 +156,7 @@ class CollaborationDevServerTest assertThat(response.statusCode()).isEqualTo(200); assertThat(new ObjectMapper().readTree(response.body()).get("threads")).hasSize(3); QContext.init(instance, CollaborationDevServer.session("alice")); + assertThat(new SharingService().link("person", 1).url()).isEqualTo("http://localhost:" + port + "/app/person/1"); AttachmentsService attachments = new AttachmentsService(); var png = attachments.list("person", 1).getFirst(); var image = get(client, attachments.downloadUrl(png.id(), true).toString()); -- 2.54.0 From ef2ee45b5ae6d14d995d6749c0e522ca14f7eae1 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:27:08 -0500 Subject: [PATCH 11/12] 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 --- CHANGELOG.md | 1 + README.md | 11 ++- .../devserver/CollaborationDevServer.java | 46 ++++++++++- .../devserver/CollaborationDevServerTest.java | 76 +++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b7203..1f13364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Fixed +- 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. diff --git a/README.md b/README.md index ed25572..71c8088 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,14 @@ 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. -Select a user with `-Dcollab.dev.user=alice|bob|admin` (one value, not the literal list) -and restart the server to switch. The MOCK session customizer supplies stable identities -and real permissions, also used by recipient access checks: +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:`): | User | Permissions | | --- | --- | diff --git a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java index 4f874c9..9c552be 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServer.java @@ -36,6 +36,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -123,6 +124,8 @@ public class CollaborationDevServer extends AbstractQQQApplication { private final QInstance instance; static final List USERS = List.of("alice", "bob", "admin"); + static final String USER_COOKIE = "collab_dev_user"; + private static final ThreadLocal REQUEST_USER = new ThreadLocal<>(); @@ -184,12 +187,49 @@ public class CollaborationDevServer extends AbstractQQQApplication .withJavalinConfigCustomizer(config -> { config.jetty.host = "127.0.0.1"; + userSelection(config); MemoryStorage.routes(config); })); } + /******************************************************************************* + ** Pick the demo user per browser, not per JVM. A request carrying ?user=bob (for + ** example the page load of /?createMockSession=true&user=bob) is remembered in a + ** cookie, and every request exposes its choice to the session customizer, which + ** QQQ calls without the request. Without either, the collab.dev.user default holds. + *******************************************************************************/ + static void userSelection(JavalinConfig config) + { + config.routes.before(context -> + { + String requested = context.queryParam("user"); + if(requested != null && USERS.contains(requested)) + { + context.cookie(USER_COOKIE, requested); + REQUEST_USER.set(requested); + return; + } + String remembered = context.cookie(USER_COOKIE); + REQUEST_USER.set(remembered != null && USERS.contains(remembered) ? remembered : null); + }); + config.routes.after(context -> REQUEST_USER.remove()); + } + + + + /******************************************************************************* + ** The user for the request being handled on this thread, else the JVM default. + *******************************************************************************/ + static String selectedUser() + { + String requested = REQUEST_USER.get(); + return (requested != null ? requested : System.getProperty("collab.dev.user", "alice")); + } + + + /******************************************************************************* ** Normal host metadata, plus the same collaboration installation as a real app. *******************************************************************************/ @@ -292,7 +332,8 @@ public class CollaborationDevServer extends AbstractQQQApplication default -> "Demo Administrator"; }; QSession session = new QSession().withUser(new QUser().withIdReference(user).withFullName(name)) - .withPermissions("person.read", "person.comment"); + .withPermissions("person.read", "person.comment") + .withValueForFrontend("user", new HashMap<>(Map.of("name", name, "email", user + "@example.com"))); if(!user.equals("bob")) { session.withPermissions("audit.read", "auditDetail.read", "auditTable.read", "auditUser.read"); @@ -324,9 +365,10 @@ public class CollaborationDevServer extends AbstractQQQApplication @Override public void customizeSession(QInstance instance, QSession target, Map context) { - QSession selected = session(System.getProperty("collab.dev.user", "alice")); + QSession selected = session(selectedUser()); target.setUser(selected.getUser()); target.setPermissions(selected.getPermissions()); + target.setValuesForFrontend(selected.getValuesForFrontend()); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java index fe08c40..945ba32 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java @@ -144,6 +144,8 @@ class CollaborationDevServerTest QSession mock = new MockAuthenticationModule().createSession(instance, Map.of("sessionUUID", UUID.randomUUID().toString())); assertThat(mock.getUser().getIdReference()).isEqualTo(user); assertThat(mock.getPermissions()).isEqualTo(CollaborationDevServer.session(user).getPermissions()); + assertThat(mock.getUser().getFullName()).isEqualTo(CollaborationDevServer.session(user).getUser().getFullName()).isNotEqualTo("Anonymous").isNotEqualTo("John Smith"); + assertThat(mock.getValuesForFrontend()).containsEntry("user", CollaborationDevServer.session(user).getValuesForFrontend().get("user")); } assertThatThrownBy(() -> CollaborationDevServer.session("unknown")).isInstanceOf(IllegalArgumentException.class); System.setProperty("collab.dev.user", "alice"); @@ -194,6 +196,80 @@ class CollaborationDevServerTest + /******************************************************************************* + ** Two browsers see two users: ?user= is remembered in a cookie, the session carries + ** the person's real name, and without a choice the JVM default still applies. + *******************************************************************************/ + @Test + void eachBrowserSessionChoosesItsOwnUser() throws Exception + { + String oldUser = System.getProperty("collab.dev.user"); + MemoryRecordStore.getInstance().reset(); + CollaborationDevServer.MemoryStorage.clear(); + QInstance instance = null; + QApplicationJavalinServer server = null; + try(HttpClient client = HttpClient.newHttpClient()) + { + System.setProperty("collab.dev.user", "admin"); + int port; + try(ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + instance = CollaborationDevServer.buildInstance(port); + CollaborationDevServer.seed(instance); + server = CollaborationDevServer.server(instance, port, false); + server.start(); + String base = "http://localhost:" + port; + + var chosen = get(client, base + "/qqq/v1/version?createMockSession=true&user=bob"); + assertThat(chosen.headers().allValues("Set-Cookie")).anySatisfy(cookie -> assertThat(cookie).startsWith("collab_dev_user=bob")); + assertThat(get(client, base + "/qqq/v1/version?user=nobody").headers().allValues("Set-Cookie")).noneMatch(cookie -> cookie.startsWith("collab_dev_user")); + + var manage = client.send(HttpRequest.newBuilder(URI.create(base + "/qqq/v1/manageSession")).timeout(Duration.ofSeconds(20)) + .header("Cookie", "collab_dev_user=bob").header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"accessToken\":\"anonymous\"}")).build(), HttpResponse.BodyHandlers.ofString()); + assertThat(new ObjectMapper().readTree(manage.body()).at("/values/user/name").asText()).as(manage.body()).isEqualTo("Bob Chen"); + assertThat(commenter(client, base, "bob")).containsExactly("bob", "Bob Chen"); + assertThat(commenter(client, base, "alice")).containsExactly("alice", "Alice Morgan"); + assertThat(commenter(client, base, "admin")).containsExactly("admin", "Demo Administrator"); + assertThat(commenter(client, base, null)).as("no choice falls back to the collab.dev.user default").containsExactly("admin", "Demo Administrator"); + assertThat(commenter(client, base, "nobody")).as("an unknown cookie falls back to the default").containsExactly("admin", "Demo Administrator"); + assertThat(CollaborationDevServer.selectedUser()).isEqualTo("admin"); + } + finally + { + if(server != null) + { + server.stop(); + } + if(instance != null) + { + PresenceRegistry.close(instance); + RealtimeBus.close(instance); + } + QContext.clear(); + QJavalinImplementation.setSessionCookieHttpOnly(false); + MemoryRecordStore.getInstance().reset(); + CollaborationDevServer.MemoryStorage.clear(); + restore("collab.dev.user", oldUser); + } + } + + /******************************************************************************* + ** Post a comment as the browser holding the given user cookie; return who the server says wrote it. + *******************************************************************************/ + private static List commenter(HttpClient client, String base, String cookieUser) throws Exception + { + String cookie = "sessionUUID=" + UUID.randomUUID() + (cookieUser == null ? "" : "; collab_dev_user=" + cookieUser); + var response = client.send(HttpRequest.newBuilder(URI.create(base + "/qqq/v1/qbit/collaboration/records/person/1/comments")).timeout(Duration.ofSeconds(20)) + .header("Cookie", cookie).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"body\":\"Who am I?\"}")).build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).as(response.body()).isEqualTo(200); + var json = new ObjectMapper().readTree(response.body()); + return (List.of(json.get("authorUserId").asText(), json.get("authorDisplayName").asText())); + } + + + /******************************************************************************* ** The demo places Collaboration inside Admin, and the admin demo user can navigate to it. *******************************************************************************/ -- 2.54.0 From d828d1677dcedf2c91412b819a422e79864b0e90 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 23:28:43 -0500 Subject: [PATCH 12/12] 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 --- CHANGELOG.md | 1 + .../timeline/TimelineService.java | 42 +++++++++++++++++- .../collaboration/TimelineServiceTest.java | 43 +++++++++++++++++++ .../devserver/CollaborationDevServerTest.java | 2 + 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f13364..8d39373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### 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). diff --git a/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java index bf86b9e..969cd9d 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java @@ -56,6 +56,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; *******************************************************************************/ public class TimelineService { + private static final int MAX_FIELDS_IN_SUMMARY = 4; + /******************************************************************************* ** Types accepts AUDIT/ACTIVITY or individual collaboration activity type names. *******************************************************************************/ @@ -154,8 +156,10 @@ public class TimelineService List result = new ArrayList<>(); for(QRecord row : headers) { - result.add(new TimelineItem("AUDIT", row.getValueLong("id"), row.getValueInstant("timestamp"), null, actors.get(row.getValueInteger("auditUserId")), - row.getValueString("message"), List.copyOf(details.getOrDefault(row.getValueLong("id"), List.of())), AuditsMetaDataProvider.TABLE_NAME_AUDIT, row.getValueString("id"))); + String actor = actors.get(row.getValueInteger("auditUserId")); + List rowDetails = List.copyOf(details.getOrDefault(row.getValueLong("id"), List.of())); + result.add(new TimelineItem("AUDIT", row.getValueLong("id"), row.getValueInstant("timestamp"), null, actor, + friendlySummary(row.getValueString("message"), actor, rowDetails, personalized), rowDetails, AuditsMetaDataProvider.TABLE_NAME_AUDIT, row.getValueString("id"))); } return (result); } @@ -165,6 +169,40 @@ public class TimelineService } } + /******************************************************************************* + ** Core words its audits "Record was Edited" or "Record was Inserted"; the timeline + ** says who did what instead: "Edited by Alice Morgan: Status, Total" or "Created by + ** Alice Morgan". The detail rows are unchanged. Any other message is kept as written. + *******************************************************************************/ + public static String friendlySummary(String message, String actor, List details, QTableMetaData table) + { + String verb = message == null ? null : message.startsWith("Record was Inserted") ? "Created" : message.startsWith("Record was Edited") ? "Edited" + : message.startsWith("Record was Deleted") ? "Deleted" : null; + if(verb == null) + { + return (message); + } + StringBuilder summary = new StringBuilder(verb); + if(actor != null && !actor.isBlank()) + { + summary.append(" by ").append(actor); + } + if(verb.equals("Edited")) + { + List labels = details.stream().map(TimelineDetail::fieldName).filter(name -> name != null && !name.isBlank()).distinct() + .map(name -> table != null && table.getFields().containsKey(name) && table.getFields().get(name).getLabel() != null ? table.getFields().get(name).getLabel() : name).toList(); + if(!labels.isEmpty()) + { + summary.append(": ").append(String.join(", ", labels.subList(0, Math.min(MAX_FIELDS_IN_SUMMARY, labels.size())))); + if(labels.size() > MAX_FIELDS_IN_SUMMARY) + { + summary.append(" and ").append(labels.size() - MAX_FIELDS_IN_SUMMARY).append(" more"); + } + } + } + return (summary.toString()); + } + /******************************************************************************* ** Explicit enforcement applies to every audit table, independent of host defaults. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java index 3a0237e..37b219d 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java @@ -26,6 +26,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.timeline.TimelineDetail; import com.kingsrook.qbits.collaboration.timeline.TimelineItem; import com.kingsrook.qbits.collaboration.timeline.TimelinePage; import com.kingsrook.qbits.collaboration.timeline.TimelineService; @@ -116,6 +117,48 @@ public class TimelineServiceTest extends PersonalServiceFixture assertThat(timeline.timeline("document", 2, null, 50, null).items()).isEmpty(); } + /******************************************************************************* + ** Core wording becomes who-did-what; detail rows and unknown messages are untouched. + *******************************************************************************/ + @Test + void auditSummariesSayWhoDidWhat() throws Exception + { + seedAudit(4, "Record was Edited", 100); + seedAudit(5, "Record was Inserted", 101); + seedAudit(6, "Record was Edited", 102); + seedAudit(7, "Record was Deleted", 103); + seed("auditDetail", new QRecord().withValue("id", 10L).withValue("auditId", 4L).withValue("fieldName", "title").withValue("oldValue", "Old").withValue("newValue", "New")); + seed("auditDetail", new QRecord().withValue("id", 11L).withValue("auditId", 4L).withValue("fieldName", "tenant").withValue("oldValue", "a").withValue("newValue", "b")); + seed("auditDetail", new QRecord().withValue("id", 12L).withValue("auditId", 4L).withValue("fieldName", "title").withValue("oldValue", "New").withValue("newValue", "Newer")); + seed("auditDetail", new QRecord().withValue("id", 13L).withValue("auditId", 5L).withValue("fieldName", "title").withValue("newValue", "Created title")); + var items = timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items(); + java.util.function.Function byId = id -> items.stream().filter(item -> item.id().equals(id)).findFirst().orElseThrow(); + assertThat(byId.apply(4L).summary()).isEqualTo("Edited by Audit Actor: Title, Tenant"); + assertThat(byId.apply(4L).details()).hasSize(3); + assertThat(byId.apply(5L).summary()).isEqualTo("Created by Audit Actor"); + assertThat(byId.apply(5L).details()).hasSize(1); + assertThat(byId.apply(6L).summary()).isEqualTo("Edited by Audit Actor"); + assertThat(byId.apply(7L).summary()).isEqualTo("Deleted by Audit Actor"); + assertThat(byId.apply(1L).summary()).as("unrecognized messages are kept").isEqualTo("Record changed"); + assertThat(items).extracting(TimelineItem::summary).noneMatch(summary -> summary.startsWith("Record was")); + + assertThat(TimelineService.friendlySummary("Record was Inserted", null, List.of(), null)).isEqualTo("Created"); + assertThat(TimelineService.friendlySummary("Record was Inserted via import", " ", List.of(), null)).isEqualTo("Created"); + assertThat(TimelineService.friendlySummary(null, "A", List.of(), null)).isNull(); + List many = java.util.stream.IntStream.rangeClosed(1, 6).mapToObj(n -> new TimelineDetail("f" + n, "a", "b", null)).toList(); + assertThat(TimelineService.friendlySummary("Record was Edited", "A", many, null)).isEqualTo("Edited by A: f1, f2, f3, f4 and 2 more"); + assertThat(TimelineService.friendlySummary("Record was Edited", "A", List.of(new TimelineDetail(null, null, null, "note"), new TimelineDetail(" ", null, null, null)), null)).isEqualTo("Edited by A"); + } + + /******************************************************************************* + ** One audit header for the first document, as a different time. + *******************************************************************************/ + private void seedAudit(long id, String message, long secondsAfter) throws Exception + { + seed("audit", new QRecord().withValue("id", id).withValue("auditTableId", 1).withValue("auditUserId", 1) + .withValue("recordId", "1").withValue("timestamp", at.plusSeconds(secondsAfter)).withValue("message", message)); + } + /******************************************************************************* ** Audit read permission and hidden fields apply. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java index 945ba32..bd80d9f 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/devserver/CollaborationDevServerTest.java @@ -120,6 +120,8 @@ class CollaborationDevServerTest assertThat(detail.oldValue()).isEqualTo("Purchasing Director"); assertThat(detail.newValue()).isEqualTo("VP of Purchasing"); })); + assertThat(new TimelineService().timeline("person", 1, null, 100, null).items()).extracting(item -> item.summary()) + .anyMatch(summary -> summary.startsWith("Edited by ") && summary.contains("Title")).noneMatch(summary -> summary.startsWith("Record was")); assertThat(new SharingService().preview("person", 1, List.of("bob")).getFirst().canRead()).isTrue(); assertThat(new SharingService().preview("sales_order", 1, List.of("bob")).getFirst().canRead()).isFalse(); QContext.setQSession(CollaborationDevServer.session("admin")); -- 2.54.0