From 711baede51552c5481255dcf207fed8ba16de6c2 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 16:42:26 -0500 Subject: [PATCH] feat: comments with mentions, follow/watch and the record activity timeline collaboration_comment (+ revisions, mentions), collaboration_follow, collaboration_activity and collaboration_follow_digest tables; comment threads with markdown bodies, @mentions checked for the recipient's access, edit history, soft delete and resolve; follow with defaults, bulk Follow/Unfollow actions and batched change notifications; a timeline merging audit and activity; a people directory. Notifications go through a CollaborationNotifier (no-op by default) until the QAlerts.notify adapter (QRun-IO/qqq#1125) is available. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +- README.md | 84 ++- docs/DESIGN.md | 19 +- .../CollaborationQBitConfig.java | 83 +++ .../CollaborationQBitProducer.java | 10 + .../collaboration/CollaborationRecords.java | 132 +++++ .../collaboration/CollaborationSchema.java | 6 + .../qbits/collaboration/RecordCursor.java | 92 +++ .../activity/ActivityWriter.java | 53 ++ .../collaboration/app/CollaborationHome.java | 13 +- .../attachments/AttachmentSecurity.java | 16 +- .../attachments/AttachmentsService.java | 15 +- .../collaboration/comments/CommentEntry.java | 32 ++ .../collaboration/comments/CommentPage.java | 31 + .../comments/CommentRevision.java | 31 + .../comments/CommentsService.java | 435 ++++++++++++++ .../comments/MentionCandidate.java | 32 ++ .../collaboration/comments/MentionEntry.java | 30 + .../favorites/FavoritesService.java | 4 + .../follow/FollowChangeBatcher.java | 214 +++++++ .../collaboration/follow/FollowEntry.java | 31 + .../collaboration/follow/FollowProcesses.java | 205 +++++++ .../collaboration/follow/FollowService.java | 176 ++++++ .../follow/SendFollowDigestsProcess.java | 99 ++++ .../model/CollaborationTables.java | 93 ++- .../model/ConversationChildren.java | 67 +++ .../notify/CollaborationNotification.java | 38 ++ .../notify/CollaborationNotifier.java | 34 ++ .../collaboration/notify/NoOpNotifier.java | 37 ++ .../collaboration/notify/Notifications.java | 105 ++++ .../notify/RecordingNotifier.java | 50 ++ .../collaboration/people/PeopleService.java | 162 ++++++ .../collaboration/people/PersonEntry.java | 31 + .../collaboration/recents/RecentsService.java | 2 + .../security/CollaborationRowSecurity.java | 65 +++ .../security/OtherUserAccess.java | 76 +++ .../security/ParentRowCustomizer.java | 72 +++ .../security/ParentTableRestriction.java | 58 ++ .../security/UserSessionSupplier.java | 37 ++ .../timeline/TimelineDetail.java | 30 + .../collaboration/timeline/TimelineItem.java | 32 ++ .../collaboration/timeline/TimelinePage.java | 31 + .../timeline/TimelineService.java | 184 ++++++ .../collaboration/CollaborationTest.java | 6 +- .../ConversationServicesTest.java | 530 ++++++++++++++++++ .../collaboration/TimelineServiceTest.java | 158 ++++++ 46 files changed, 3712 insertions(+), 39 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/CollaborationRecords.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/RecordCursor.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/activity/ActivityWriter.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/comments/CommentEntry.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/comments/CommentPage.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/comments/CommentRevision.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/comments/MentionCandidate.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/comments/MentionEntry.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/follow/FollowChangeBatcher.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/follow/FollowEntry.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/follow/FollowProcesses.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/follow/FollowService.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/follow/SendFollowDigestsProcess.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotification.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotifier.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/notify/NoOpNotifier.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/notify/RecordingNotifier.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/people/PersonEntry.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/security/OtherUserAccess.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/security/ParentRowCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/security/ParentTableRestriction.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/security/UserSessionSupplier.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineDetail.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineItem.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelinePage.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 61af8ab..bd6d3eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Added +- Phase-e comments/mentions, one-level threads, edit revisions, author/admin soft deletion, resolve/reopen, ranked mention candidates and keyset pagination on QQQ 4.1.0-RC.5. +- Comments, Comment History, Mentions, Followers, Activity and durable Follow Digests tables, child-list widgets, service-only writes, shared parent-table query restrictions, schema indexes and real home counts. +- Lazy people-directory upserts with ten-minute throttling from comments/follow/favorites/recents/attachments entry points; bounded active-name/email search. +- Configurable notifier and recipient-session stand-ins, default no-op and recording transports, conservative unknown-access handling and RC.5 after-commit delivery with fresh access checks. +- Follow preferences, generic streamed ETL Follow/Unfollow actions, configured RC.5 change listeners and a hidden minute scheduler for five-minute digests. +- Timeline merging enforced core audit reads with collaboration activity, field-visibility filtering and tuple cursors. Attachments now record FILE_ADDED/FILE_REMOVED activity. +- Memory-backed tests for recipient access, transactions, moderation, pagination, real ETL Review/Result, digest batching, directory ranking, schema and audit timeline security. Existing JaCoCo gates retained; endpoint publication deferred to #1128. + - 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. @@ -18,4 +26,4 @@ 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. +- 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 4daccb5..d9c5bcd 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. Phases d to i are planned; stored settings are not yet applied as runtime overrides and people are not yet populated automatically. +Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Tags (d) and phases f to i remain planned; stored settings are not yet applied as runtime overrides. Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md). @@ -47,19 +47,25 @@ Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation | `collaboration_user` (People) | Unique session user ID, display name, email, avatar URL, last seen timestamp and active flag. Service-managed, read-only to users. | | `collaboration_favorite` (Favorites) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `sortOrder`, optional `folder`. Unique on user + type + name + record ID. Owner-locked, audit off. | | `collaboration_recent` (Recently Viewed) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `lastViewedAt`, `viewCount`. Same unique key and owner lock; audit off. | - | `collaboration_attachment` (Attachments) | Parent table and record ID, file metadata, SHA-256, bucket/key, optional description/thumbnail key, author and parent-deleted flag. FIELD audit; parent-based security. Preview/download URLs are virtual, never stored. | -All five include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold. +| `collaboration_comment` (Comments) | Markdown body, author, root/reply reference, optional field anchor, latest thread activity, edit/resolve/delete timestamps. FIELD audit and body search. | +| `collaboration_comment_revision` (Comment History) | Prior body, edit timestamp and editor; comment child list. | +| `collaboration_mention` (Mentions) | Unique comment + mentioned user, recipient visibility and actual delivery timestamp; comment child list. | +| `collaboration_follow` (Followers) | Unique parent + user, reason, change/comment notification preferences and mute timestamp. Admin browse only; Followers child list on Comments. | +| `collaboration_activity` (Activity) | Event time, actor, type, summary and reference. Read-only admin main table. | +| `collaboration_follow_digest` (Follow Digests) | Service-managed pending five-minute batches, unique parent + recipient, due timestamp and changed-field summaries. Admin browse only, outside navigation. | -The home reports persisted enabled settings by scope type, people known and people seen within the inclusive last seven days, plus Favorites and Recently viewed row counts and distinct user counts, and attachment count and total bytes. Attachment aggregates are explicitly approximate until core can mirror parent locks. Recent activity is based on `lastSeenAt`, independently of the directory's `active` account flag. Empty states explain missing data. These counts do not represent resolved feature enablement on host tables. +All eleven 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. ## Permissions | Permission | Behavior | |---|---| -| `collaboration.view` | Browse Attachments, read Feature Settings, People, the app and home. Not required for personal favorites or recents. | -| `collaboration.admin` | Write feature settings, read the app/home, access all users' favorite/recent rows, and run recents retention. Can moderate others' attachments after the parent check. Does not permit direct People or attachment table writes. | +| `collaboration.view` | Browse Comments, their revisions/mentions and Attachments; read Feature Settings, People, the app and home. Not required for personal favorites or recents. | +| `collaboration.admin` | Write feature settings, read the app/home, access all users' favorite/recent rows, and run recents retention. Can moderate others' attachments and delete others' comments after the parent check. Browses Followers, Activity and Follow Digests. Does not permit editing another author's comment or bypassing service-only table writes. | | `collaboration.fields.manage` | Reserved constant for the later custom-field designer. | | `collaboration.tags.manage` | Reserved constant for later tag management. | @@ -88,12 +94,12 @@ List recents = RecentsService.list(50); - Star and unstar are idempotent. New stars append to the existing sort order. Reorder moves the supplied IDs first and preserves the order of omitted favorites; folder entries may be null to clear a folder. All supplied IDs must belong to the current user. - Targets are checked using core table/app/process/report permissions; record targets use `GetAction` and the session's record locks. Invisible and missing targets both fail with `Not found`. -- Lists refresh labels, using one batched record query per target table per batch. Favorites retain unavailable entries as `available=false`, `label="Unavailable"`, `href=null`; the cached label is never returned. Unstar remains possible after access is lost. Recents drop unavailable entries and refill from older rows up to the requested limit (1–50). +- Lists refresh labels, using one batched record query per target table per batch. Favorites retain unavailable entries as `available=false`, `label="Unavailable"`, `href=null`; the cached label is never returned. Unstar remains possible after access is lost. Recents drop unavailable entries and refill from older rows up to the requested limit (1–50). - `FavoriteEntry` contains `id`, `targetType`, `targetName`, `recordId`, `label`, `folder`, `sortOrder`, `available` and `href`. Recents use the same target DTO in newest-first order; their folder and sort order are null. `viewCount` and `lastViewedAt` remain available on the underlying recent table. - Hrefs mirror Next's accepted flat routes: `/app//`, `/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. +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 @@ -123,31 +129,81 @@ 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, activity entries and realtime events remain future integrations. The table declares `fileName`/`description` search fields and uses normal QQQ writes, so host-configured record-change/ESB listeners receive events; this qbit does not install an ESB publication or Quick Search provider. +After a parent deletion commits, trusted host code can call `new AttachmentCleanup().onParentsDeleted(table, ids)`. It deletes objects and rows in batches and is retryable. It cannot check an already-deleted parent and must never be exposed as a user endpoint. Wiring to core's `RecordReferenceCleanupInterface` waits for its release. UI API endpoints 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. Tests use real QQQ memory actions and record locks plus an in-memory `AttachmentStorage` adapter. Object storage RC.2 exposes no fake bucket; its provider conformance suite uses MinIO/Testcontainers. This suite requires no Docker and does not claim MinIO/S3 conformance. +## Comments, follow and timeline + +Run services in the caller's `QContext`. Register host tables before producing the qbit when using the follow configuration lists: + +```java +config.withNotifier(new QCodeReference(MyCollaborationNotifier.class)) + .withUserSessionSupplier(new QCodeReference(MyUserSessions.class)) + .withFollowActionTables(List.of("order")) + .withFollowChangeTables(List.of("order")) + .withSchedulerName("scheduler"); + +CommentsService comments = new CommentsService(); +CommentEntry root = comments.post("order", "123", "Please review @[Sam](user:sam)", null, null); +CommentEntry reply = comments.post("order", "123", "Reply", root.id(), null); +CommentPage page = comments.list("order", "123", null, 20); +comments.edit(reply.id(), "Updated reply"); +comments.history(reply.id()); +comments.resolve(root.id()); +comments.reopen(root.id()); +comments.delete(reply.id()); +comments.mentionCandidates("order", "123", "Sam"); + +FollowService follow = new FollowService(); +follow.follow("order", "123", null, null); +follow.state("order", "123"); +follow.followers("order", "123"); +follow.unfollow("order", "123"); +TimelinePage timeline = new TimelineService().timeline("order", "123", null, 20, Set.of()); +``` + +Classes live in the `comments`, `follow`, `timeline`, `people` and `notify` packages under `com.kingsrook.qbits.collaboration`. + +- Comments require visible parents and the `comment` feature check (explicit `
.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. +- `CommentEntry` includes author display name, capabilities, deleted/resolved state, mentions with visibility, and ordered replies. `CommentPage` pages 1–50 root threads by latest activity then ID, with replies oldest first. Pass `nextCursor` unchanged. Cursors are opaque, versioned Base64 tuple encodings, not authorization tokens. +- Manual/assigned follow creation defaults change notifications on; other reasons default them off. Comment notifications default on. Existing preferences survive automatic follows and repeated calls; non-null arguments to `follow` change preferences. Follow needs only parent READ. The internal `ensureFollower` helper also verifies other-user access. Muted subscriptions are skipped until `mutedUntil` expires. +- `FollowProcesses.processesFor(tableName)` returns the same Follow/Unfollow processes for manual host registration. Review performs no writes; Result rechecks permissions and reports successes/failures. They work with the host's actual primary-key field, not just `id`, and are never navigation entries. +- RC.5 has `RecordChangeListenerInterface` and `QBackendTransaction.addAfterCommitCallback`. The configured UPDATE listener queues changed-field summaries after commit, excluding the actor's own subscription. The trusted `FollowChangeBatcher.recordChange` helper may also be called by host code after its write commits. `collaborationSendFollowDigests` runs every minute and sends batches due five minutes after their first change, rechecking preferences, mute and recipient access. Pending data survives process restarts. Run one digest scheduler per installation; local synchronization is not a distributed lock. A transport crash after delivery but before row deletion can cause a retry; an adapter needing exactly-once delivery must provide idempotency. +- Notifications carry recipient ID, title, markdown body, MENTION/SHARE/FOLLOW/TASK category, source table/record and `/app/
/` href. Without a transport, `NoOpNotifier` sends nothing and activity remains available. With a `QContext` transaction, delivery waits for commit and is discarded on rollback; otherwise it follows the write immediately. Access is checked again at delivery. Mention `notifiedAt` is recorded only after a real transport returns successfully. `RecordingNotifier` provides an in-memory test sink. +- Timeline merges collaboration activity and RC.5 `audit`/`auditDetail`/`auditTable`/`auditUser` through enforced QueryActions. Missing or denied audit access omits that stream. Audit details exclude fields absent from the caller's personalized table. Core audits have actor names but no session user IDs, so their `actorUserId` is null. Paging uses `(at, kind, id)`; `types` accepts AUDIT, ACTIVITY or individual activity type names. Each source query is bounded before merging. +- `PeopleService.touch()` upserts the signed-in user's full name and last-seen timestamp at most once per ten minutes, using the persisted timestamp. Comments/follow and existing favorites/recents/attachment entry points call it. `search(q, limit)` matches active users' names/emails case-insensitively. QUser in RC.5 has no email/avatar fields; trusted directory integrations can enrich those columns. Mention candidates rank followers/commenters before applying the 50-result limit. + +The main comments/activity tables, revision/mention child joins and Followers child list are ordinary QQQ metadata. Direct API writes are refused; services authorize the actual parent. Shared `ParentTableRestriction` protects attachment and conversation queries/counts/aggregates; parent post-filtering protects row reads. Aggregate counts remain approximate and browse pages can be short for unmirrored parent locks. Raw persistence, activity writing and change-hook helpers are trusted internal boundaries and must not become public endpoints. + +UI API endpoints 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. + ## Configuration | Property | Default | Meaning | |---|---|---| -| `backendName` | Required | Existing QQQ backend used by all five tables. | -| `schedulerName` | None | Existing scheduler for the hidden daily recents trim process (86400-second interval and initial delay). Without it, the process is available for explicit admin/system execution. | +| `backendName` | Required | Existing QQQ backend used by all eleven tables. | +| `schedulerName` | None | Existing scheduler for daily recents trimming and minute-by-minute follow digest delivery. Without it, both hidden processes remain available for explicit admin/system execution. | | `attachmentsBucketName` | `collaboration` | PRIVATE object-storage bucket alias for new uploads; existing rows retain their bucket alias. | | `defaultMaxBytes` | `104857600` (100 MiB) | Positive byte limit, capped by the bucket's `maxUploadBytes`. | | `blockedExtensions` | Empty | Case-insensitive extension denylist, with or without leading dots. | | `recentsPerUser` | `200` | Positive retention count per user. Enforced by the trim process. | +| `notifier` | `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. | +| `followActionTables` | Empty | Existing host tables to receive standard Follow and Unfollow Review/Result processes. | +| `followChangeTables` | Empty | Existing host tables whose UPDATE events should produce five-minute follow digests. | | `appName` | `collaboration` | Metadata name of the app. | | `parentAppName` | None | Optional existing parent, such as `admin`. | -| `adminApp` | `true` | Set false for tables without the app and widget. | +| `adminApp` | `true` | Set false to omit the app/home. Record child-list widgets remain available. | ## Adopting in another app 1. Register the host backend and optional Admin app, then invoke the producer above. 2. For PostgreSQL, generate `CollaborationSchema.postgresScript(config)` and apply it with the host's migration tool. It creates tables and unique indexes, never executes DDL, and does not migrate existing schemas. QQQ owns date/default values on writes. 3. Grant `collaboration.view` and `collaboration.admin` as appropriate; wire the host's audit persistence for FIELD audit events. -4. Validate the assembled instance. Navigate to Collaboration for Attachments, Feature Settings and People. Expect empty tables until settings or trusted directory integrations provide rows. -5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. Favorites, recents and attachments services can be used now. This phase installs no endpoints or host-table overlays; recents scheduling is opt-in. +4. Validate the assembled instance. Navigate to Collaboration for Comments, Attachments, Activity (admins), Feature Settings and People. People populate as signed-in users call collaboration services. +5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. Favorites, recents, attachments, comments, follow and timeline services can be used now. This phase installs no endpoints or host-table overlays; scheduled work is opt-in. ## Build diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fcd8240..cd1875c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,11 +2,11 @@ The authoritative design is [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md` ([develop document](https://github.com/QRun-IO/qqq/blob/develop/docs/COLLABORATION.md)). -Collaboration adds metadata-driven record features to QQQ: attachments, tags, custom fields, comments and mentions, presence, sharing, favorites, follow, timeline, recents, tasks and assignment. Core owns security, query and runtime metadata contracts; this qbit owns visible `collaboration_*` tables and feature services. Implemented: phase-a configuration, feature settings and people tables, plus phase-b favorites/recents tables, services, owner locks, retention and home counts against QQQ 4.1.0-RC.5. The phase-b UI API endpoints await the released extension point from QRun-IO/qqq#1128. The override and record-feature summary providers await released core contracts; phase-c attachments are implemented on object storage 0.1.0-RC.2; phases d to i remain planned. The table below is the full roadmap from the specification, not a list of shipped functionality. +Collaboration 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 and phases f to i remain planned. The table below is the full roadmap from the specification, not a list of shipped functionality. | Phase | Core | Qbit | Next UI | |---|---|---|---| -| a. Contracts | `RecordFeaturesMetaData` + resolver + frontend publication; `RecordReference`, `RecordReferenceTableMetaData` and the security rules of §3.2 (table filter, mirrored locks, parent check, post-filter, cleanup hook); feature permissions in the catalog; `RecordFeatureSummaryProviderInterface` and `GET /table/{t}/{pk}/features`; `RuntimeMetaDataProviderInterface` and overlay (generation only, no fields yet); `RecordAccessCheckerInterface`, `UserSessionProviderInterface` | repo scaffold, qbit config, `collaboration_feature_setting`, `collaboration_user`, override provider, home skeleton | types for `RecordFeaturesV1` | +| a. Contracts | `RecordFeaturesMetaData` + resolver + frontend publication; `RecordReference`, `RecordReferenceTableMetaData` and the security rules of §3.2 (table filter, mirrored locks, parent check, post-filter, cleanup hook); feature permissions in the catalog; `RecordFeatureSummaryProviderInterface` and `GET /table/{t}/{pk}/features`; `RuntimeMetaDataProviderInterface` and overlay (generation only, no fields yet); `RecordAccessCheckerInterface`, `UserSessionProviderInterface` | repo scaffold, qbit config, `collaboration_feature_setting`, `collaboration_user`, override provider, home skeleton | types for `RecordFeaturesV1` | | b. Favorites, recents, palette | | favorites, recents, endpoints, summary provider | star in record and table headers, favorites menu, palette favorites group, server recents | | c. Attachments | | attachments with object storage, previews and thumbnails, delete action | Files tab, upload, preview | | d. Tags | `QSubQuery`; virtual field criteria expander in query, count, aggregate and search; `CONTAINS_ALL`; `#tag` search | tags, vocabulary modes, bulk actions, filter field | tag chips, Tags filter and column, `#tag` in palette and search | @@ -25,6 +25,19 @@ Collaboration adds metadata-driven record features to QQQ: attachments, tags, cu - Uploads spool to bounded temporary disk while hashing; no full-file heap buffer. The bucket cap also applies. Keys contain a UUID and safe basename; renames do not move bytes. Object/row writes are ordered with best-effort compensation, not a distributed transaction. - The object-storage adapter uses the calling-qbit system facade only after authorization. Soft deletion follows bucket policy. Object retention failures leave retryable rows. In-memory adapter tests exercise QQQ actions and security; provider conformance stays with object storage's MinIO suite. - Previews/downloads are virtual fields in the RECORD_VIEW alternative section (RC.5 disallows virtual fields in base sections). Five-minute URLs are never stored. RC.2 supports attachment filename overrides, but not explicit inline overrides; uploads carry an inline header. There is no thumbnail generator or generated thumbnail file. -- Delete attachments uses standard streamed ETL. Attach files is omitted for API/panel uploads. Cleanup is a trusted public after-commit callback pending RecordReferenceCleanupInterface. Endpoints await #1128; runtime feature settings, activity and realtime publication are future integrations. Native search fields are present; ESB and Quick Search publication are host integrations. +- Delete attachments uses standard streamed ETL. Attach files is omitted for API/panel uploads. Cleanup 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. 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. +- **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. diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java index d63345d..e8503ad 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java @@ -23,7 +23,9 @@ package com.kingsrook.qbits.collaboration; import java.util.List; +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.qbits.QBitConfig; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -36,6 +38,10 @@ public class CollaborationQBitConfig implements QBitConfig public static final String TABLE_NAME_PREFIX = "collaboration_"; public static final String PERMISSION_BASE_NAME = "collaboration"; + private QCodeReference notifier = new QCodeReference(NoOpNotifier.class); + private QCodeReference userSessionSupplier = null; + private List followActionTables = List.of(); + private List followChangeTables = List.of(); private String backendName; private String schedulerName; private String appName = "collaboration"; @@ -244,6 +250,15 @@ public class CollaborationQBitConfig implements QBitConfig { assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors); } + assertCondition(notifier != null, "notifier is required", errors); + for(List tables : List.of(followActionTables, followChangeTables)) + { + for(String table : tables) + { + assertCondition(table != null && !table.startsWith(TABLE_NAME_PREFIX) && instance.getTable(table) != null + && instance.getTable(table).getPrimaryKeyField() != null, "Follow configuration needs a host table with a primary key: " + table, errors); + } + } assertCondition(StringUtils.hasContent(attachmentsBucketName), "attachmentsBucketName is required", errors); assertCondition(defaultMaxBytes != null && defaultMaxBytes > 0, "defaultMaxBytes must be positive", errors); assertCondition(blockedExtensions != null && blockedExtensions.stream().allMatch(value -> value != null && !value.isBlank()), "blockedExtensions must contain nonblank extensions", errors); @@ -356,4 +371,72 @@ public class CollaborationQBitConfig implements QBitConfig setBlockedExtensions(blockedExtensions); return (this); } + + /******************************************************************************* + ** Get notifier. + *******************************************************************************/ + public QCodeReference getNotifier() + { + return (notifier); + } + + /******************************************************************************* + ** Configure notifier. + *******************************************************************************/ + public CollaborationQBitConfig withNotifier(QCodeReference notifier) + { + this.notifier = notifier; + return (this); + } + + /******************************************************************************* + ** Get userSessionSupplier. + *******************************************************************************/ + public QCodeReference getUserSessionSupplier() + { + return (userSessionSupplier); + } + + /******************************************************************************* + ** Configure userSessionSupplier. + *******************************************************************************/ + public CollaborationQBitConfig withUserSessionSupplier(QCodeReference userSessionSupplier) + { + this.userSessionSupplier = userSessionSupplier; + return (this); + } + + /******************************************************************************* + ** Get followActionTables. + *******************************************************************************/ + public List getFollowActionTables() + { + return (followActionTables); + } + + /******************************************************************************* + ** Configure followActionTables. + *******************************************************************************/ + public CollaborationQBitConfig withFollowActionTables(List followActionTables) + { + this.followActionTables = followActionTables; + return (this); + } + + /******************************************************************************* + ** Get followChangeTables. + *******************************************************************************/ + public List getFollowChangeTables() + { + return (followChangeTables); + } + + /******************************************************************************* + ** Configure followChangeTables. + *******************************************************************************/ + public CollaborationQBitConfig withFollowChangeTables(List followChangeTables) + { + this.followChangeTables = followChangeTables; + 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 133d374..3aaaa54 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java @@ -128,6 +128,15 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer bit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig()); + } + + /******************************************************************************* + ** Restrict a service query to one canonical parent. + *******************************************************************************/ + public static QQueryFilter parent(String table, String id) + { + return (new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table) + .withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false)); + } + + /******************************************************************************* + ** Raw reads retain transaction visibility but omit presentation customizers. + *******************************************************************************/ + public static List query(String table, QQueryFilter filter) throws QException + { + QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(QContext.getQBackendTransaction()); + input.setTableMetaData(QContext.getQInstance().getTable(table).clone().withCustomizers(null)); + input.withPermissionBypass("Trusted collaboration service performs parent/ownership authorization."); + return (new QueryAction().execute(input).getRecords().stream().map(QRecord::new).toList()); + } + + /******************************************************************************* + ** Persist generated keys and propagate record validation errors. + *******************************************************************************/ + public static QRecord insert(String table, QRecord row) throws QException + { + InsertInput input = new InsertInput(table).withRecords(List.of(row)).withTransaction(QContext.getQBackendTransaction()); + input.withPermissionBypass("Trusted collaboration service authorized this write."); + List result = new InsertAction().execute(input).getRecords(); + check(result); + return (result.getFirst()); + } + + /******************************************************************************* + ** Only supplied fields are changed; preserve auditing. + *******************************************************************************/ + public static void update(String table, QRecord row) throws QException + { + UpdateInput input = new UpdateInput(table).withRecords(List.of(row)).withTransaction(QContext.getQBackendTransaction()); + input.setTableMetaData(QContext.getQInstance().getTable(table).clone().withCustomizers(null)); + input.withPermissionBypass("Trusted collaboration service authorized this write."); + check(new UpdateAction().execute(input).getRecords()); + } + + /******************************************************************************* + ** Delete a service-selected row. + *******************************************************************************/ + public static void delete(String table, Serializable id) throws QException + { + DeleteInput input = new DeleteInput(table).withPrimaryKeys(List.of(id)).withTransaction(QContext.getQBackendTransaction()); + input.setTableMetaData(QContext.getQInstance().getTable(table).clone().withCustomizers(null)); + input.withPermissionBypass("Trusted collaboration service authorized this deletion."); + check(new DeleteAction().execute(input).getRecordsWithErrors()); + } + + /******************************************************************************* + ** Core actions can return validation errors instead of throwing. + *******************************************************************************/ + private static void check(List rows) throws QException + { + if(rows != null) + { + for(QRecord row : rows) + { + if(row.getErrors() != null && !row.getErrors().isEmpty()) + { + throw (new QException("Could not save collaboration record: " + row.getErrors())); + } + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java index 894c5b5..868f1a5 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java @@ -70,6 +70,12 @@ public final class CollaborationSchema } } statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_parent ON collaboration_attachment (table_name, record_id)"); + statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_parent ON collaboration_comment (table_name, record_id, last_activity_at, id)"); + statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_created ON collaboration_comment (table_name, record_id, create_date)"); + statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_thread ON collaboration_comment (thread_id, create_date, id)"); + statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_revision_comment ON collaboration_comment_revision (comment_id, edited_at, id)"); + statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_activity_parent ON collaboration_activity (table_name, record_id, at, id)"); + statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_digest_due ON collaboration_follow_digest (due_at)"); return (String.join(";\n", statements) + ";\n"); } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/RecordCursor.java b/src/main/java/com/kingsrook/qbits/collaboration/RecordCursor.java new file mode 100644 index 0000000..9a706ca --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/RecordCursor.java @@ -0,0 +1,92 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; + +/******************************************************************************* + ** Versioned opaque keyset cursor shared by comments and timeline. + *******************************************************************************/ +public record RecordCursor(Instant at, String kind, Long id) +{ + /******************************************************************************* + ** No record data is embedded in a cursor. + *******************************************************************************/ + public String encode() + { + return (Base64.getUrlEncoder().withoutPadding().encodeToString(("1\n" + at + "\n" + kind + "\n" + id).getBytes(StandardCharsets.UTF_8))); + } + + /******************************************************************************* + ** Reject malformed cursors with a consistent service error. + *******************************************************************************/ + public static RecordCursor decode(String cursor) throws QException + { + try + { + String[] parts = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8).split("\n", -1); + if(parts.length != 4 || !parts[0].equals("1") || !java.util.List.of("COMMENT", "ACTIVITY", "AUDIT").contains(parts[2])) + { + throw (new IllegalArgumentException("Invalid cursor")); + } + return (new RecordCursor(Instant.parse(parts[1]), parts[2], Long.valueOf(parts[3]))); + } + catch(RuntimeException e) + { + throw (new QException("Invalid cursor", e)); + } + } + + /******************************************************************************* + ** Strict ordering for descending (time, kind, id) pagination. + *******************************************************************************/ + public boolean isBefore(RecordCursor other) + { + int time = at.compareTo(other.at); + int type = kind.compareTo(other.kind); + return (time < 0 || (time == 0 && (type < 0 || (type == 0 && id < other.id)))); + } + + /******************************************************************************* + ** Apply the same tuple ordering in backend queries, including equal timestamps. + *******************************************************************************/ + public QQueryFilter beforeFilter(String atField, String streamKind) + { + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) + .withSubFilter(new QQueryFilter().withCriteria(atField, QCriteriaOperator.LESS_THAN, at)); + if(streamKind.compareTo(kind) <= 0) + { + QQueryFilter equal = new QQueryFilter().withCriteria(atField, QCriteriaOperator.EQUALS, at); + if(streamKind.equals(kind)) + { + equal.withCriteria("id", QCriteriaOperator.LESS_THAN, id.toString()); + } + filter.withSubFilter(equal); + } + return (filter); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/activity/ActivityWriter.java b/src/main/java/com/kingsrook/qbits/collaboration/activity/ActivityWriter.java new file mode 100644 index 0000000..b262bbb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/activity/ActivityWriter.java @@ -0,0 +1,53 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.activity; + +import java.time.Instant; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qbits.collaboration.CollaborationSecurity; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Internal activity writer; the calling service has already checked the parent. + *******************************************************************************/ +public final class ActivityWriter +{ + /******************************************************************************* + ** Static service boundary. + *******************************************************************************/ + private ActivityWriter() + { + } + + /******************************************************************************* + ** Store references, never copies of comment bodies or attachment contents. + *******************************************************************************/ + public static void write(String table, String recordId, String type, String summary, String refTable, String refId) throws QException + { + CollaborationRecords.insert(CollaborationTables.ACTIVITY, new QRecord().withValue("tableName", table).withValue("recordId", recordId) + .withValue("at", Instant.now()).withValue("actorUserId", CollaborationSecurity.userId(QContext.getQSession())) + .withValue("type", type).withValue("summary", summary).withValue("refTableName", refTable).withValue("refId", refId).withValue("parentDeleted", false)); + } +} 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 1ce8dac..3b14560 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java @@ -82,7 +82,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.ATTACHMENT, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER))); + .withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER))); app.setParentAppName(config.getParentAppName()); return (app); } @@ -140,6 +140,17 @@ public final class CollaborationHome personalSummary(html, CollaborationTables.FAVORITE, "Favorites"); personalSummary(html, CollaborationTables.RECENT, "Recently viewed"); attachmentSummary(html); + 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()); } 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 22f128d..792097f 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentSecurity.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentSecurity.java @@ -21,18 +21,13 @@ package com.kingsrook.qbits.collaboration.attachments; -import java.util.List; import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissionChecker; -import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; -import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrCountInputInterface; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; @@ -66,15 +61,6 @@ public class AttachmentSecurity implements CustomPermissionChecker *******************************************************************************/ 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); + 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 89ceb06..24103ba 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java @@ -92,6 +92,7 @@ public class AttachmentsService *******************************************************************************/ public AttachmentEntry upload(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); ParentRecordAccess.requireFeature(tableName, recordId, "attach"); String user = requireUser(); String id = canonicalId(tableName, recordId); @@ -130,6 +131,7 @@ public class AttachmentsService { storage.put(bucket, PutRequest.of(key, upload).withContentLength(size).withContentType(type).withContentDisposition("inline")); } + boolean rowSaved = false; try { QRecord row = new QRecord().withValue("tableName", tableName).withValue("recordId", id).withValue("fileName", name) @@ -140,13 +142,18 @@ public class AttachmentsService input.withPermissionBypass("AttachmentsService checked parent and attach permission before upload."); List records = new InsertAction().execute(input).getRecords(); AttachmentRecords.checkErrors(records); + rowSaved = true; + com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(tableName, id, "FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, records.getFirst().getValueString("id")); return (entry(records.getFirst())); } catch(QException | RuntimeException failure) { try { - storage.delete(bucket, key); + if(!rowSaved) + { + storage.delete(bucket, key); + } } catch(RuntimeException cleanupFailure) { @@ -180,6 +187,7 @@ public class AttachmentsService *******************************************************************************/ public List list(String tableName, Serializable recordId) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); String id = canonicalId(tableName, recordId); List entries = new ArrayList<>(); for(QRecord row : AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName) @@ -197,6 +205,7 @@ public class AttachmentsService *******************************************************************************/ public URL downloadUrl(Serializable attachmentId, boolean inline) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); return (url(requireRow(attachmentId), inline)); } @@ -218,6 +227,7 @@ public class AttachmentsService *******************************************************************************/ public void delete(List attachmentIds) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); List rows = new ArrayList<>(); for(Serializable id : attachmentIds.stream().distinct().toList()) { @@ -226,6 +236,7 @@ public class AttachmentsService for(QRecord row : rows) { deleteStoredRow(row); + com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(row.getValueString("tableName"), row.getValueString("recordId"), "FILE_REMOVED", "File removed", CollaborationTables.ATTACHMENT, row.getValueString("id")); } } @@ -235,6 +246,7 @@ public class AttachmentsService *******************************************************************************/ public AttachmentEntry rename(Serializable attachmentId, String fileName, String description) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); QRecord row = requireMutable(attachmentId); QRecord change = new QRecord().withValue("id", row.getValue("id")); if(fileName != null) @@ -265,6 +277,7 @@ public class AttachmentsService *******************************************************************************/ public QRecord requireMutable(Serializable attachmentId) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); QRecord row = requireRow(attachmentId); requireUser(); if(!canDelete(row)) diff --git a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentEntry.java b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentEntry.java new file mode 100644 index 0000000..c486b39 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentEntry.java @@ -0,0 +1,32 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.comments; + +import java.time.Instant; +import java.util.List; + +/******************************************************************************* + ** Thread item; deleted bodies are never returned. + *******************************************************************************/ +public record CommentEntry(Integer id, Integer threadId, Integer parentCommentId, String body, String fieldName, String authorUserId, String authorDisplayName, Instant createdAt, Instant editedAt, Boolean deleted, Boolean resolved, Boolean canEdit, Boolean canDelete, List mentions, List replies) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentPage.java b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentPage.java new file mode 100644 index 0000000..9081909 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentPage.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.comments; + +import java.util.List; + +/******************************************************************************* + ** A page counts root threads; replies stay grouped under each root. + *******************************************************************************/ +public record CommentPage(List threads, String nextCursor) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentRevision.java b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentRevision.java new file mode 100644 index 0000000..4b27d75 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentRevision.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.comments; + +import java.time.Instant; + +/******************************************************************************* + ** Prior body and the identity/time of the edit that replaced it. + *******************************************************************************/ +public record CommentRevision(Integer id, String body, Instant editedAt, String editedByUserId) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java new file mode 100644 index 0000000..4fadf33 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/comments/CommentsService.java @@ -0,0 +1,435 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.comments; + +import java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qbits.collaboration.PersonalRecords; +import com.kingsrook.qbits.collaboration.RecordCursor; +import com.kingsrook.qbits.collaboration.activity.ActivityWriter; +import com.kingsrook.qbits.collaboration.follow.FollowEntry; +import com.kingsrook.qbits.collaboration.follow.FollowService; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.notify.CollaborationNotification; +import com.kingsrook.qbits.collaboration.notify.Notifications; +import com.kingsrook.qbits.collaboration.people.PeopleService; +import com.kingsrook.qbits.collaboration.people.PersonEntry; +import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; +import com.kingsrook.qbits.collaboration.security.OtherUserAccess; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +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.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Record comments, one-level threads, edit history and recipient-safe mentions. + *******************************************************************************/ +public class CommentsService +{ + private static final Pattern MENTION = Pattern.compile("@\\[[^\\]\\r\\n]+]\\(user:([^()\\s]+)\\)"); + + /******************************************************************************* + ** Validate all input and the optional reply target before inserting anything. + *******************************************************************************/ + public CommentEntry post(String table, Serializable recordId, String body, Integer parentCommentId, String fieldName) throws QException + { + new PeopleService().touch(); + ParentRecordAccess.requireFeature(table, recordId, "comment"); + String id = FollowService.canonicalId(table, recordId); + Set mentions = parse(body); + Integer rootId = null; + if(parentCommentId != null) + { + QRecord parent = requireComment(parentCommentId); + if(!table.equals(parent.getValueString("tableName")) || !id.equals(parent.getValueString("recordId"))) + { + throw (new QNotFoundException("Not found")); + } + rootId = parent.getValueInteger("threadId") == null ? parent.getValueInteger("id") : parent.getValueInteger("threadId"); + } + if(fieldName != null && !QContext.getQInstance().getTable(table).getFields().containsKey(fieldName)) + { + throw (new QException("Unknown field")); + } + Instant now = Instant.now(); + QRecord row = CollaborationRecords.insert(CollaborationTables.COMMENT, reference(table, id).withValue("body", body) + .withValue("threadId", rootId).withValue("parentCommentId", rootId).withValue("fieldName", fieldName) + .withValue("authorUserId", PersonalRecords.currentUserId()).withValue("lastActivityAt", now)); + bumpRoot(row, now); + Set notified = processMentions(row, mentions); + new FollowService().ensureFollower(PersonalRecords.currentUserId(), table, id, "COMMENTED"); + ActivityWriter.write(table, id, "COMMENTED", "Comment added", CollaborationTables.COMMENT, row.getValueString("id")); + notifyFollowers(row, notified); + return (entry(row, List.of())); + } + + /******************************************************************************* + ** Keyset-page root threads by latest activity; fetch replies oldest first. + *******************************************************************************/ + public CommentPage list(String table, Serializable recordId, String cursor, Integer limit) throws QException + { + new PeopleService().touch(); + String id = FollowService.canonicalId(table, recordId); + requireLimit(limit); + RecordCursor before = cursor == null ? null : RecordCursor.decode(cursor); + if(before != null && !before.kind().equals("COMMENT")) + { + throw (new QException("Invalid comment cursor")); + } + QQueryFilter filter = CollaborationRecords.parent(table, id).withCriteria("threadId", QCriteriaOperator.IS_BLANK) + .withOrderBy(new QFilterOrderBy("lastActivityAt", false)).withOrderBy(new QFilterOrderBy("id", false)); + if(before != null) + { + filter.withSubFilter(before.beforeFilter("lastActivityAt", "COMMENT")); + } + List roots = CollaborationRecords.query(CollaborationTables.COMMENT, filter.withLimit(limit + 1)); + List result = new ArrayList<>(); + for(QRecord row : roots.stream().limit(limit).toList()) + { + List replies = new ArrayList<>(); + for(QRecord reply : CollaborationRecords.query(CollaborationTables.COMMENT, CollaborationRecords.parent(table, id) + .withCriteria("threadId", QCriteriaOperator.EQUALS, row.getValue("id")) + .withOrderBy(new QFilterOrderBy("createDate")).withOrderBy(new QFilterOrderBy("id")))) + { + replies.add(entry(reply, List.of())); + } + result.add(entry(row, replies)); + } + return (new CommentPage(List.copyOf(result), roots.size() > limit ? key(roots.get(limit - 1)).encode() : null)); + } + + /******************************************************************************* + ** Authors alone can edit; administrators cannot rewrite another author's body. + *******************************************************************************/ + public CommentEntry edit(Serializable commentId, String body) throws QException + { + new PeopleService().touch(); + QRecord row = requireComment(commentId); + requireAuthor(row); + requireCommentFeature(row); + Set mentions = parse(body); + Instant now = Instant.now(); + CollaborationRecords.insert(CollaborationTables.COMMENT_REVISION, reference(row.getValueString("tableName"), row.getValueString("recordId")) + .withValue("commentId", row.getValue("id")).withValue("body", row.getValueString("body")).withValue("editedAt", now) + .withValue("editedByUserId", PersonalRecords.currentUserId())); + CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("id")).withValue("body", body) + .withValue("editedAt", now).withValue("lastActivityAt", now)); + row.withValue("body", body).withValue("editedAt", now); + bumpRoot(row, now); + processMentions(row, mentions); + ActivityWriter.write(row.getValueString("tableName"), row.getValueString("recordId"), "COMMENT_EDITED", "Comment edited", CollaborationTables.COMMENT, row.getValueString("id")); + return (entry(row, List.of())); + } + + /******************************************************************************* + ** Soft delete; moderators still need to see the actual parent. + *******************************************************************************/ + public void delete(Serializable commentId) throws QException + { + new PeopleService().touch(); + QRecord row = requireComment(commentId); + if(!isAdmin()) + { + requireAuthor(row); + requireCommentFeature(row); + } + Instant now = Instant.now(); + CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("id")) + .withValue("deletedAt", now).withValue("deletedByUserId", PersonalRecords.currentUserId()).withValue("lastActivityAt", now)); + bumpRoot(row, now); + } + + /******************************************************************************* + ** Resolve only a root thread. + *******************************************************************************/ + public void resolve(Serializable commentId) throws QException + { + setResolved(commentId, true); + } + + /******************************************************************************* + ** Reopen only a root thread. + *******************************************************************************/ + public void reopen(Serializable commentId) throws QException + { + setResolved(commentId, false); + } + + /******************************************************************************* + ** Anyone able to comment can change root resolution state. + *******************************************************************************/ + private void setResolved(Serializable commentId, boolean resolved) throws QException + { + new PeopleService().touch(); + QRecord row = requireComment(commentId); + requireCommentFeature(row); + if(row.getValue("threadId") != null) + { + throw (new QException("Resolve or reopen the root comment.")); + } + Instant now = Instant.now(); + CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("id")) + .withValue("resolvedAt", resolved ? now : null).withValue("resolvedByUserId", resolved ? PersonalRecords.currentUserId() : null).withValue("lastActivityAt", now)); + ActivityWriter.write(row.getValueString("tableName"), row.getValueString("recordId"), resolved ? "COMMENT_RESOLVED" : "COMMENT_REOPENED", + resolved ? "Thread resolved" : "Thread reopened", CollaborationTables.COMMENT, row.getValueString("id")); + } + + /******************************************************************************* + ** History follows parent read access without requiring edit permission. + *******************************************************************************/ + public List history(Serializable commentId) throws QException + { + new PeopleService().touch(); + QRecord row = requireComment(commentId); + return (CollaborationRecords.query(CollaborationTables.COMMENT_REVISION, new QQueryFilter().withCriteria("commentId", QCriteriaOperator.EQUALS, row.getValue("id")) + .withOrderBy(new QFilterOrderBy("editedAt")).withOrderBy(new QFilterOrderBy("id"))).stream() + .map(revision -> new CommentRevision(revision.getValueInteger("id"), revision.getValueString("body"), revision.getValueInstant("editedAt"), revision.getValueString("editedByUserId"))).toList()); + } + + /******************************************************************************* + ** Rank current participants before other matching active directory users. + *******************************************************************************/ + public List mentionCandidates(String table, Serializable recordId, String q) throws QException + { + new PeopleService().touch(); + String id = FollowService.canonicalId(table, recordId); + Set participants = new HashSet<>(); + new FollowService().followers(table, id).forEach(follower -> participants.add(follower.userId())); + CollaborationRecords.query(CollaborationTables.COMMENT, CollaborationRecords.parent(table, id)).forEach(row -> participants.add(row.getValueString("authorUserId"))); + List people = new PeopleService().search(q, 50, participants); + List result = new ArrayList<>(); + for(PersonEntry person : people) + { + result.add(new MentionCandidate(person, new OtherUserAccess().canRead(person.userId(), table, id))); + } + return (List.copyOf(result)); + } + + /******************************************************************************* + ** Validate before persistence; deduplicate and reject more than fifty mentions. + *******************************************************************************/ + private Set parse(String body) throws QException + { + if(body == null || body.isBlank() || body.length() > 20000) + { + throw (new QException("Comment body must contain 1 to 20000 characters.")); + } + Set users = new LinkedHashSet<>(); + Matcher matcher = MENTION.matcher(body); + while(matcher.find()) + { + if(matcher.group(1).length() > 255) + { + throw (new QException("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.")); + } + return (users); + } + + /******************************************************************************* + ** Keep historical mentions so an edit never re-notifies an already mentioned user. + *******************************************************************************/ + private Set processMentions(QRecord row, Set users) throws QException + { + Set existing = new HashSet<>(); + mentionRows(row).forEach(mention -> existing.add(mention.getValueString("mentionedUserId"))); + Set notified = new HashSet<>(); + String table = row.getValueString("tableName"); + String id = row.getValueString("recordId"); + for(String user : users) + { + if(existing.contains(user)) + { + continue; + } + boolean visible = new OtherUserAccess().canRead(user, table, id).orElse(false); + QRecord mention = CollaborationRecords.insert(CollaborationTables.MENTION, reference(table, id).withValue("commentId", row.getValue("id")) + .withValue("mentionedUserId", user).withValue("visible", visible)); + if(visible) + { + new FollowService().ensureFollower(user, table, id, "MENTIONED"); + if(!user.equals(PersonalRecords.currentUserId())) + { + notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment", mention.getValueInteger("id")); + notified.add(user); + } + } + } + return (notified); + } + + /******************************************************************************* + ** Mention recipients get one notification; muted and author subscriptions are skipped. + *******************************************************************************/ + private void notifyFollowers(QRecord row, Set mentioned) throws QException + { + String author = PersonalRecords.currentUserId(); + for(FollowEntry follower : new FollowService().followers(row.getValueString("tableName"), row.getValueString("recordId"))) + { + if(!author.equals(follower.userId()) && !mentioned.contains(follower.userId()) && Boolean.TRUE.equals(follower.notifyOnComment()) + && (follower.mutedUntil() == null || !follower.mutedUntil().isAfter(Instant.now()))) + { + notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on a followed record", null); + } + } + } + + /******************************************************************************* + ** Delivery re-checks recipient access, including after transaction commit. + *******************************************************************************/ + private void notify(QRecord row, String user, CollaborationNotification.Category category, String title, Integer mentionId) throws QException + { + Notifications.send(new CollaborationNotification(user, title, row.getValueString("body"), category, + row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId"))), + mentionId == null ? null : () -> CollaborationRecords.update(CollaborationTables.MENTION, + new QRecord().withValue("id", mentionId).withValue("notifiedAt", Instant.now()))); + } + + /******************************************************************************* + ** Generic child reference for query security. + *******************************************************************************/ + private QRecord reference(String table, String id) + { + return (new QRecord().withValue("tableName", table).withValue("recordId", id).withValue("parentDeleted", false)); + } + + /******************************************************************************* + ** Parent visibility is checked before exposing even ownership metadata. + *******************************************************************************/ + private QRecord requireComment(Serializable id) throws QException + { + List rows = CollaborationRecords.query(CollaborationTables.COMMENT, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id)); + if(rows.isEmpty() || Boolean.TRUE.equals(rows.getFirst().getValueBoolean("parentDeleted"))) + { + throw (new QNotFoundException("Not found")); + } + QRecord row = rows.getFirst(); + ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId")); + return (row); + } + + /******************************************************************************* + ** Deleted comments cannot be edited by their author. + *******************************************************************************/ + private void requireAuthor(QRecord row) throws QException + { + if(!PersonalRecords.currentUserId().equals(row.getValueString("authorUserId")) || row.getValue("deletedAt") != null) + { + throw (new QPermissionDeniedException("Only the author can edit or delete this comment.")); + } + } + + /******************************************************************************* + ** Shared feature permission stand-in. + *******************************************************************************/ + private void requireCommentFeature(QRecord row) throws QException + { + ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "comment"); + } + + /******************************************************************************* + ** Update root ordering whenever a reply changes. + *******************************************************************************/ + private void bumpRoot(QRecord row, Instant at) throws QException + { + if(row.getValue("threadId") != null) + { + CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("threadId")).withValue("lastActivityAt", at)); + } + } + + /******************************************************************************* + ** Comment cursor key uses root activity, not creation time. + *******************************************************************************/ + private RecordCursor key(QRecord row) + { + return (new RecordCursor(row.getValueInstant("lastActivityAt"), "COMMENT", row.getValueLong("id"))); + } + + /******************************************************************************* + ** Bound public collection responses. + *******************************************************************************/ + private void requireLimit(Integer limit) throws QException + { + if(limit == null || limit < 1 || limit > 50) + { + throw (new QException("Limit must be between 1 and 50")); + } + } + + /******************************************************************************* + ** Child rows are safe after comment or parent authorization. + *******************************************************************************/ + private List mentionRows(QRecord row) throws QException + { + return (CollaborationRecords.query(CollaborationTables.MENTION, new QQueryFilter().withCriteria("commentId", QCriteriaOperator.EQUALS, row.getValue("id")))); + } + + /******************************************************************************* + ** Capabilities reflect ownership and the same write permission as mutations. + *******************************************************************************/ + private CommentEntry entry(QRecord row, List replies) throws QException + { + boolean author = PersonalRecords.currentUserId().equals(row.getValueString("authorUserId")); + boolean deleted = row.getValue("deletedAt") != null; + boolean feature; + try + { + requireCommentFeature(row); + feature = true; + } + catch(QPermissionDeniedException expected) + { + feature = false; + } + List mentions = mentionRows(row).stream().map(mention -> new MentionEntry(mention.getValueString("mentionedUserId"), mention.getValueBoolean("visible"))).toList(); + return (new CommentEntry(row.getValueInteger("id"), row.getValueInteger("threadId"), row.getValueInteger("parentCommentId"), deleted ? null : row.getValueString("body"), + row.getValueString("fieldName"), row.getValueString("authorUserId"), new PeopleService().displayName(row.getValueString("authorUserId")), row.getValueInstant("createDate"), + row.getValueInstant("editedAt"), deleted, row.getValue("resolvedAt") != null, author && feature && !deleted, !deleted && ((author && feature) || isAdmin()), mentions, replies)); + } + + /******************************************************************************* + ** Moderation is distinct from permission to edit another author's text. + *******************************************************************************/ + private boolean isAdmin() + { + return (QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/comments/MentionCandidate.java b/src/main/java/com/kingsrook/qbits/collaboration/comments/MentionCandidate.java new file mode 100644 index 0000000..3e9f465 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/comments/MentionCandidate.java @@ -0,0 +1,32 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.comments; + +import java.util.Optional; +import com.kingsrook.qbits.collaboration.people.PersonEntry; + +/******************************************************************************* + ** Tri-state access is exposed only after the caller can read the parent. + *******************************************************************************/ +public record MentionCandidate(PersonEntry person, Optional canRead) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/comments/MentionEntry.java b/src/main/java/com/kingsrook/qbits/collaboration/comments/MentionEntry.java new file mode 100644 index 0000000..f0004a7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/comments/MentionEntry.java @@ -0,0 +1,30 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.comments; + + +/******************************************************************************* + ** Mention visibility is the recipient access result at processing time. + *******************************************************************************/ +public record MentionEntry(String userId, Boolean visible) +{ +} 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 59811cd..4bd4990 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java @@ -57,6 +57,7 @@ public final class FavoritesService *******************************************************************************/ public static void star(String targetType, String targetName, Serializable recordId) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); String userId = PersonalRecords.currentUserId(); FavoriteEntry target = TargetResolver.require(targetType, targetName, recordId); QQueryFilter filter = PersonalRecords.targetFilter(targetType, targetName, target.recordId()); @@ -90,6 +91,7 @@ public final class FavoritesService *******************************************************************************/ public static void unstar(String targetType, String targetName, Serializable recordId) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); String id = TargetResolver.recordId(targetType, targetName, recordId); List rows = PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.targetFilter(targetType, targetName, id)); PersonalRecords.delete(CollaborationTables.FAVORITE, rows.stream().map(row -> row.getValue("id")).toList()); @@ -102,6 +104,7 @@ public final class FavoritesService *******************************************************************************/ public static List list() throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); return (TargetResolver.refresh(PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.ownFilter() .withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id"))))); } @@ -114,6 +117,7 @@ public final class FavoritesService *******************************************************************************/ public static void reorder(List ids, Map folders) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); List rows = PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.ownFilter() .withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id"))); Set owned = new HashSet<>(rows.stream().map(row -> row.getValueInteger("id")).toList()); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowChangeBatcher.java b/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowChangeBatcher.java new file mode 100644 index 0000000..e104fad --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowChangeBatcher.java @@ -0,0 +1,214 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.follow; + +import java.time.Clock; +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qbits.collaboration.CollaborationSecurity; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.notify.CollaborationNotification; +import com.kingsrook.qbits.collaboration.notify.Notifications; +import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeEvent; +import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListenerInterface; +import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType; +import com.kingsrook.qqq.backend.core.context.CapturedContext; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Durable five-minute follow digests. RC.5 listeners queue only committed updates. + ** The scheduler must run once per installation; local synchronization coordinates + ** listener instances, while unique keys protect subscription/batch identity. + *******************************************************************************/ +public class FollowChangeBatcher implements RecordChangeListenerInterface +{ + private final Clock clock; + + /******************************************************************************* + ** Core creates a listener instance for each event. + *******************************************************************************/ + public FollowChangeBatcher() + { + this(Clock.systemUTC()); + } + + /******************************************************************************* + ** Inject time for deterministic batching tests. + *******************************************************************************/ + public FollowChangeBatcher(Clock clock) + { + this.clock = clock; + } + + /******************************************************************************* + ** Only configured host-table updates need old records fetched by core. + *******************************************************************************/ + @Override + public boolean appliesTo(String tableName, RecordChangeType type) + { + return (type == RecordChangeType.UPDATE && CollaborationRecords.config().getFollowChangeTables().contains(tableName)); + } + + /******************************************************************************* + ** Copy event data before retaining anything in an after-commit callback. + *******************************************************************************/ + @Override + public void onRecordsChanged(RecordChangeEvent event) throws QException + { + if(!appliesTo(event.getTableName(), event.getType())) + { + return; + } + String actor = CollaborationSecurity.userId(QContext.getQSession()); + String primaryKey = QContext.getQInstance().getTable(event.getTableName()).getPrimaryKeyField(); + CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession()); + for(int i = 0; i < event.getRecords().size(); i++) + { + QRecord row = event.getRecords().get(i); + QRecord old = event.getOldRecords() == null ? null : event.getOldRecords().get(i); + Set fields = new LinkedHashSet<>(); + for(String field : row.getValues().keySet()) + { + if(!field.equals(primaryKey) && !field.equals("modifyDate") && (old == null || !Objects.equals(row.getValue(field), old.getValue(field)))) + { + fields.add(field); + } + } + String id = row.getValueString(primaryKey); + if(!fields.isEmpty()) + { + if(event.getTransaction() == null) + { + recordChange(event.getTableName(), id, actor, fields); + } + else + { + event.getTransaction().addAfterCommitCallback(() -> + { + try + { + QContext.withTemporaryContext(context, () -> recordChange(event.getTableName(), id, actor, fields)); + } + catch(QException e) + { + throw (new IllegalStateException("Could not queue follow digest", e)); + } + }); + } + } + } + } + + /******************************************************************************* + ** Trusted host-hook helper; invoke after the host update commits. Values are + ** never copied into digests, and a recipient's own changes are never queued. + *******************************************************************************/ + public void recordChange(String table, String id, String actor, Set fields) throws QException + { + if(fields.isEmpty()) + { + return; + } + synchronized(CollaborationRecords.config()) + { + Instant now = clock.instant(); + for(QRecord follower : CollaborationRecords.query(CollaborationTables.FOLLOW, CollaborationRecords.parent(table, id))) + { + String user = follower.getValueString("userId"); + if(user.equals(actor) || !eligible(follower, now)) + { + continue; + } + List batches = CollaborationRecords.query(CollaborationTables.FOLLOW_DIGEST, + CollaborationRecords.parent(table, id).withCriteria("userId", QCriteriaOperator.EQUALS, user)); + Set changes = new LinkedHashSet<>(); + if(!batches.isEmpty()) + { + changes.addAll(Arrays.asList(batches.getFirst().getValueString("changes").split("\n"))); + } + for(String field : fields) + { + changes.add(field + " (by " + (actor == null ? "system" : actor) + ")"); + } + QRecord batch = new QRecord().withValue("changes", String.join("\n", changes)); + if(batches.isEmpty()) + { + CollaborationRecords.insert(CollaborationTables.FOLLOW_DIGEST, batch.withValue("tableName", table).withValue("recordId", id) + .withValue("userId", user).withValue("dueAt", now.plusSeconds(300)).withValue("parentDeleted", false)); + } + else + { + CollaborationRecords.update(CollaborationTables.FOLLOW_DIGEST, batch.withValue("id", batches.getFirst().getValue("id"))); + } + } + } + } + + /******************************************************************************* + ** The scheduler invokes outside a transaction so deletion follows delivery. + ** Re-check current subscription, mute and access; failures retain a retryable row. + *******************************************************************************/ + public void sendDue() throws QException + { + new SendFollowDigestsProcess().checkPermissionsThrowing(null, null); + if(QContext.getQBackendTransaction() != null) + { + throw (new QException("Send follow digests outside a transaction.")); + } + synchronized(CollaborationRecords.config()) + { + Instant now = clock.instant(); + for(QRecord batch : CollaborationRecords.query(CollaborationTables.FOLLOW_DIGEST, new QQueryFilter().withCriteria("dueAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))) + { + String table = batch.getValueString("tableName"); + String id = batch.getValueString("recordId"); + String user = batch.getValueString("userId"); + List follows = CollaborationRecords.query(CollaborationTables.FOLLOW, + CollaborationRecords.parent(table, id).withCriteria("userId", QCriteriaOperator.EQUALS, user)); + if(!follows.isEmpty() && eligible(follows.getFirst(), now)) + { + Notifications.send(new CollaborationNotification(user, "Changes to a followed record", batch.getValueString("changes"), + CollaborationNotification.Category.FOLLOW, table, id, Notifications.link(table, id))); + } + CollaborationRecords.delete(CollaborationTables.FOLLOW_DIGEST, batch.getValue("id")); + } + } + } + + /******************************************************************************* + ** Mute ends at its timestamp, including exact equality with now. + *******************************************************************************/ + private boolean eligible(QRecord row, Instant now) + { + return (Boolean.TRUE.equals(row.getValueBoolean("notifyOnChange")) + && (row.getValueInstant("mutedUntil") == null || !row.getValueInstant("mutedUntil").isAfter(now))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowEntry.java b/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowEntry.java new file mode 100644 index 0000000..f1a3c1b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowEntry.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.follow; + +import java.time.Instant; + +/******************************************************************************* + ** Record follower preferences; users can only mutate their own state publicly. + *******************************************************************************/ +public record FollowEntry(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowProcesses.java b/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowProcesses.java new file mode 100644 index 0000000..76a701a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowProcesses.java @@ -0,0 +1,205 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.follow; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.permissions.UseTablePermissionCustomPermissionChecker; +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.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +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 ETL Follow/Unfollow actions for any host table with a key. + *******************************************************************************/ +public final class FollowProcesses +{ + /******************************************************************************* + ** Metadata factory. + *******************************************************************************/ + private FollowProcesses() + { + } + + /******************************************************************************* + ** Hosts can register these directly or configure followActionTables. + *******************************************************************************/ + public static List processesFor(String tableName) + { + return (List.of(process(tableName, true), process(tableName, false))); + } + + /******************************************************************************* + ** Process table association puts actions on records, never in navigation. + *******************************************************************************/ + private static QProcessMetaData process(String table, boolean follow) + { + Map values = new HashMap<>(); + values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table); + values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table); + values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, (follow ? "Follow" : "Unfollow") + " the selected records."); + return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values) + .withName("collaboration" + (follow ? "Follow_" : "Unfollow_") + table).withLabel(follow ? "Follow" : "Unfollow") + .withTableName(table).withIcon(new QIcon(follow ? "notifications" : "notifications_off")) + .withPermissionRules(new QPermissionRules().withCustomPermissionChecker(UseTablePermissionCustomPermissionChecker.build(table, TablePermissionSubType.READ)))); + } + + /******************************************************************************* + ** Stable process metadata, not client values, chooses table and operation. + *******************************************************************************/ + private static String table(RunBackendStepInput input) + { + return (input.getProcess().getTableName()); + } + + /******************************************************************************* + ** Summary renderer has both Review and Result wording. + *******************************************************************************/ + private static ProcessSummaryLine line(Status status, String future, String past) + { + ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null); + line.setSingularFutureMessage("record " + future); + line.setPluralFutureMessage("records " + future); + line.setSingularPastMessage("record " + past); + line.setPluralPastMessage("records " + past); + return (line); + } + + /******************************************************************************* + ** Dry-run authorization re-reads parents under the current session. + *******************************************************************************/ + public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private final ProcessSummaryLine selected = line(Status.OK, "will be processed", "processed"); + private final ProcessSummaryLine skipped = line(Status.WARNING, "is unavailable (skipped)", "skipped"); + + /******************************************************************************* + ** No writes during review. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String table = table(input); + String key = QContext.getQInstance().getTable(table).getPrimaryKeyField(); + for(QRecord row : input.getRecords()) + { + try + { + QRecord parent = ParentRecordAccess.requireVisibleParent(table, row.getValue(key)); + output.getRecords().add(parent); + selected.incrementCountAndAddPrimaryKey(parent.getValue(key)); + } + catch(QNotFoundException expected) + { + skipped.incrementCountAndAddPrimaryKey(row.getValue(key)); + } + } + } + + /******************************************************************************* + ** Authorized and skipped selections. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + selected.addSelfToListIfAnyCount(lines); + skipped.addSelfToListIfAnyCount(lines); + return (lines); + } + } + + /******************************************************************************* + ** Re-authorize during load; report actual successes and failures. + *******************************************************************************/ + public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final ProcessSummaryLine done = line(Status.OK, "will be processed", "processed"); + private final ProcessSummaryLine failed = line(Status.ERROR, "cannot be processed", "failed (retry required)"); + + /******************************************************************************* + ** A failed record does not prevent other selections being processed. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String table = table(input); + String key = QContext.getQInstance().getTable(table).getPrimaryKeyField(); + for(QRecord row : input.getRecords()) + { + try + { + if(input.getProcessName().equals("collaborationFollow_" + table)) + { + new FollowService().follow(table, row.getValue(key), null, null); + } + else + { + new FollowService().unfollow(table, row.getValue(key)); + } + done.incrementCountAndAddPrimaryKey(row.getValue(key)); + } + catch(QException expected) + { + failed.incrementCountAndAddPrimaryKey(row.getValue(key)); + } + } + } + + /******************************************************************************* + ** Result includes review skips and load failures. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + done.addSelfToListIfAnyCount(lines); + if(getTransformStep() instanceof Review review) + { + review.skipped.addSelfToListIfAnyCount(lines); + } + failed.addSelfToListIfAnyCount(lines); + return (lines); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowService.java b/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowService.java new file mode 100644 index 0000000..15286cb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/follow/FollowService.java @@ -0,0 +1,176 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.follow; + +import java.io.Serializable; +import java.util.List; +import java.util.Optional; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +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.ParentRecordAccess; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Following requires parent visibility, without a separate feature permission. + *******************************************************************************/ +public class FollowService +{ + /******************************************************************************* + ** Nullable preferences retain existing settings on repeat calls. + *******************************************************************************/ + public FollowEntry follow(String table, Serializable recordId, Boolean notifyOnChange, Boolean notifyOnComment) throws QException + { + new PeopleService().touch(); + String id = canonicalId(table, recordId); + QRecord row = ensure(PersonalRecords.currentUserId(), table, id, "MANUAL"); + QRecord update = new QRecord().withValue("id", row.getValue("id")); + if(notifyOnChange != null) + { + update.withValue("notifyOnChange", notifyOnChange); + } + if(notifyOnComment != null) + { + update.withValue("notifyOnComment", notifyOnComment); + } + CollaborationRecords.update(CollaborationTables.FOLLOW, update); + return (state(table, id).orElseThrow()); + } + + /******************************************************************************* + ** Remove only the caller's subscription and queued digest. + *******************************************************************************/ + public void unfollow(String table, Serializable recordId) throws QException + { + new PeopleService().touch(); + String id = canonicalId(table, recordId); + for(String target : List.of(CollaborationTables.FOLLOW, CollaborationTables.FOLLOW_DIGEST)) + { + for(QRecord row : CollaborationRecords.query(target, mine(table, id))) + { + CollaborationRecords.delete(target, row.getValue("id")); + } + } + } + + /******************************************************************************* + ** Current-user state; absent means not followed. + *******************************************************************************/ + public Optional state(String table, Serializable recordId) throws QException + { + new PeopleService().touch(); + List rows = CollaborationRecords.query(CollaborationTables.FOLLOW, mine(table, canonicalId(table, recordId))); + return (rows.stream().findFirst().map(FollowService::entry)); + } + + /******************************************************************************* + ** Parent readers may see its followers independently of admin table browsing. + *******************************************************************************/ + public List followers(String table, Serializable recordId) throws QException + { + new PeopleService().touch(); + return (CollaborationRecords.query(CollaborationTables.FOLLOW, CollaborationRecords.parent(table, canonicalId(table, recordId))) + .stream().map(FollowService::entry).toList()); + } + + /******************************************************************************* + ** Internal automatic follow; unknown access never grants a subscription. + *******************************************************************************/ + public void ensureFollower(String userId, String table, Serializable recordId, String reason) throws QException + { + new PeopleService().touch(); + String id = canonicalId(table, recordId); + if(!userId.equals(PersonalRecords.currentUserId()) && !new OtherUserAccess().canRead(userId, table, id).orElse(false)) + { + return; + } + ensure(userId, table, id, reason); + } + + /******************************************************************************* + ** Existing preferences always win over automatic follow defaults. + *******************************************************************************/ + private QRecord ensure(String user, String table, String id, String reason) throws QException + { + if(!List.of("MANUAL", "AUTHOR", "COMMENTED", "MENTIONED", "ASSIGNED", "SHARED").contains(reason)) + { + throw (new QException("Unknown follow reason")); + } + QQueryFilter filter = CollaborationRecords.parent(table, id).withCriteria("userId", QCriteriaOperator.EQUALS, user); + List rows = CollaborationRecords.query(CollaborationTables.FOLLOW, filter); + if(!rows.isEmpty()) + { + return (rows.getFirst()); + } + QRecord row; + try + { + row = CollaborationRecords.insert(CollaborationTables.FOLLOW, new QRecord().withValue("tableName", table).withValue("recordId", id) + .withValue("userId", user).withValue("reason", reason).withValue("notifyOnChange", reason.equals("MANUAL") || reason.equals("ASSIGNED")) + .withValue("notifyOnComment", true).withValue("parentDeleted", false)); + } + catch(QException e) + { + rows = CollaborationRecords.query(CollaborationTables.FOLLOW, filter); + if(rows.isEmpty()) + { + throw (e); + } + return (rows.getFirst()); + } + ActivityWriter.write(table, id, "FOLLOWED", "Record followed", CollaborationTables.FOLLOW, row.getValueString("id")); + return (row); + } + + /******************************************************************************* + ** Normalize numeric aliases to the parent's actual primary key. + *******************************************************************************/ + public static String canonicalId(String table, Serializable id) throws QException + { + return (ParentRecordAccess.requireVisibleParent(table, id).getValueString(QContext.getQInstance().getTable(table).getPrimaryKeyField())); + } + + /******************************************************************************* + ** Ownership filter always accompanies parent selection. + *******************************************************************************/ + private QQueryFilter mine(String table, String id) throws QPermissionDeniedException + { + return (CollaborationRecords.parent(table, id).withCriteria("userId", QCriteriaOperator.EQUALS, PersonalRecords.currentUserId())); + } + + /******************************************************************************* + ** Immutable public view. + *******************************************************************************/ + private static FollowEntry entry(QRecord row) + { + return (new FollowEntry(row.getValueInteger("id"), row.getValueString("userId"), row.getValueString("reason"), + row.getValueBoolean("notifyOnChange"), row.getValueBoolean("notifyOnComment"), row.getValueInstant("mutedUntil"))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/follow/SendFollowDigestsProcess.java b/src/main/java/com/kingsrook/qbits/collaboration/follow/SendFollowDigestsProcess.java new file mode 100644 index 0000000..bec9eee --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/follow/SendFollowDigestsProcess.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.follow; + + +import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; +import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.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.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; + + +/******************************************************************************* + ** Hidden minute-by-minute digest job. Runs with its caller's session, which must be a + ** scheduler system session or collaboration admin; never elevates a user. + *******************************************************************************/ +public class SendFollowDigestsProcess implements BackendStep, CustomPermissionChecker +{ + public static final String NAME = "collaborationSendFollowDigests"; + + + + /******************************************************************************* + ** Always register the backend-only process; scheduling is opt-in. + *******************************************************************************/ + public static QProcessMetaData metadata(CollaborationQBitConfig config) + { + QProcessMetaData process = new QProcessMetaData().withName(NAME).withLabel("Send follow digests").withIsHidden(true).withIcon(new QIcon("history")) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION) + .withPermissionBaseName("collaboration").withCustomPermissionChecker(new QCodeReference(SendFollowDigestsProcess.class))) + .withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(SendFollowDigestsProcess.class))); + if(config.getSchedulerName() != null) + { + process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(60).withInitialDelaySeconds(60)); + } + return (process); + } + + + + /******************************************************************************* + ** Hidden is a presentation setting, so explicitly guard execution as well. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + QSession session = QContext.getQSession(); + if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(CollaborationPermissions.ADMIN))) + { + throw (new QPermissionDeniedException("This needs the collaboration.admin permission.")); + } + } + + + + /******************************************************************************* + ** Deliver due batches using fresh recipient permissions. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + checkPermissionsThrowing(input, null); + new FollowChangeBatcher().sendDue(); + } +} 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 0142bac..1cd0672 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,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; *******************************************************************************/ public final class CollaborationTables { + public static final String COMMENT = "collaboration_comment"; + public static final String COMMENT_REVISION = "collaboration_comment_revision"; + public static final String MENTION = "collaboration_mention"; + public static final String FOLLOW = "collaboration_follow"; + public static final String ACTIVITY = "collaboration_activity"; + public static final String FOLLOW_DIGEST = "collaboration_follow_digest"; public static final String ATTACHMENT = "collaboration_attachment"; public static final String FEATURE_SETTING = "collaboration_feature_setting"; public static final String FAVORITE = "collaboration_favorite"; @@ -85,6 +91,7 @@ public final class CollaborationTables public static List possibleValueSources() { return (List.of( + QPossibleValueSource.newForTable(COMMENT), QPossibleValueSource.newForTable(COMMENT_REVISION), QPossibleValueSource.newForTable(MENTION), QPossibleValueSource.newForTable(FOLLOW), QPossibleValueSource.newForTable(ACTIVITY), QPossibleValueSource.newForTable(FOLLOW_DIGEST), enumSource(TARGET_TYPE, "TABLE", "APP", "RECORD", "PROCESS", "REPORT", "SAVED_VIEW"), QPossibleValueSource.newForTable(ATTACHMENT), QPossibleValueSource.newForTable(FAVORITE), QPossibleValueSource.newForTable(RECENT), enumSource(SCOPE_TYPE, "INSTANCE", "APP", "TABLE"), @@ -152,7 +159,8 @@ public final class CollaborationTables .withField(new QFieldMetaData("lastViewedAt", QFieldType.DATE_TIME).withLabel("Last Viewed At").withIsRequired(true)) .withField(new QFieldMetaData("viewCount", QFieldType.INTEGER).withLabel("View Count").withIsRequired(true).withDefaultValue(1)) .withSection(new QFieldSection("details", new QIcon("history"), Tier.T2, List.of("label", "lastViewedAt", "viewCount")).withLabel("Details")); - List tables = List.of(settings, people, favorites, recents, attachment(config)); + List tables = new ArrayList<>(List.of(settings, people, favorites, recents, attachment(config))); + tables.addAll(conversationTables(config)); for(QTableMetaData table : tables) { table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates")); @@ -252,4 +260,87 @@ public final class CollaborationTables List.of("id", "bucketName", "storageKey", "thumbnailKey", "sha256")).withLabel("Storage"))); } + + /******************************************************************************* + ** Conversation tables share immutable parent references and service-only writes. + *******************************************************************************/ + private static List conversationTables(CollaborationQBitConfig config) + { + QTableMetaData comments = referenced(config, COMMENT, "Comments", "forum") + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)) + .withSearchFields(List.of("body")) + .withField(new QFieldMetaData("threadId", QFieldType.INTEGER).withPossibleValueSourceName(COMMENT)) + .withField(new QFieldMetaData("parentCommentId", QFieldType.INTEGER).withPossibleValueSourceName(COMMENT)) + .withField(new QFieldMetaData("body", QFieldType.TEXT).withMaxLength(20000)) + .withField(string("fieldName", "Field", 128)) + .withField(string("authorUserId", "Author", 255).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(new QFieldMetaData("deletedAt", QFieldType.DATE_TIME)) + .withField(string("deletedByUserId", "Deleted By", 255)) + .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)); + 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(new QFieldMetaData("visible", QFieldType.BOOLEAN).withIsRequired(true)) + .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(string("reason", "Reason", 32).withIsRequired(true)) + .withField(new QFieldMetaData("notifyOnChange", QFieldType.BOOLEAN).withIsRequired(true)) + .withField(new QFieldMetaData("notifyOnComment", QFieldType.BOOLEAN).withIsRequired(true)) + .withField(new QFieldMetaData("mutedUntil", QFieldType.DATE_TIME)) + .withUniqueKey(new UniqueKey("tableName", "recordId", "userId")); + QTableMetaData activity = referenced(config, ACTIVITY, "Activity", "history") + .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(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(new QFieldMetaData("dueAt", QFieldType.DATE_TIME).withIsRequired(true)) + .withField(new QFieldMetaData("changes", QFieldType.TEXT)) + .withUniqueKey(new UniqueKey("tableName", "recordId", "userId")); + List tables = List.of(comments, revisions, mentions, follows, activity, digests); + for(QTableMetaData table : tables) + { + table.withSection(new QFieldSection("details", new QIcon("info"), Tier.T1, + table.getFields().keySet().stream().filter(name -> !List.of("createDate", "modifyDate", "tableName", "recordId", "parentDeleted").contains(name)).toList()).withLabel("Details")); + for(QFieldMetaData field : table.getFields().values()) + { + field.setIsEditable(false); + } + } + return (tables); + } + + /******************************************************************************* + ** Denormalized references on children permit the same pre-query security. + *******************************************************************************/ + private static QTableMetaData referenced(CollaborationQBitConfig config, String name, String label, String icon) + { + return (base(config, name, label, icon) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE)) + .withPermissionRules(CollaborationPermissions.permissionRules() + .withDenyBehavior(com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior.HIDDEN) + .withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.security.CollaborationRowSecurity.class))) + .withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(com.kingsrook.qbits.collaboration.security.ParentRowCustomizer.class)) + .withField(string("tableName", "Table", 128).withIsRequired(true)) + .withField(string("recordId", "Record ID", 64).withIsRequired(true)) + .withField(new QFieldMetaData("parentDeleted", QFieldType.BOOLEAN).withIsRequired(true).withDefaultValue(false)) + .withRecordLabelFormatAndFields("%s", "id") + .withSection(new QFieldSection("parent", new QIcon("link"), Tier.T2, List.of("tableName", "recordId", "parentDeleted")).withLabel("Parent"))); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java b/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java new file mode 100644 index 0000000..d70cf47 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java @@ -0,0 +1,67 @@ +/* + * 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.model; + +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; + +/******************************************************************************* + ** Standard child lists keep revision, mention and follower tables out of navigation. + *******************************************************************************/ +public final class ConversationChildren +{ + /******************************************************************************* + ** Static metadata helper. + *******************************************************************************/ + private ConversationChildren() + { + } + + /******************************************************************************* + ** Install after tables have been registered. + *******************************************************************************/ + public static void install(QInstance instance) + { + child(instance, CollaborationTables.COMMENT_REVISION, "Comment History", List.of(new JoinOn("id", "commentId"))); + child(instance, CollaborationTables.MENTION, "Mentions", List.of(new JoinOn("id", "commentId"))); + child(instance, CollaborationTables.FOLLOW, "Followers", List.of(new JoinOn("tableName", "tableName"), new JoinOn("recordId", "recordId"))); + } + + /******************************************************************************* + ** Child records are managed by services, so the add button is disabled. + *******************************************************************************/ + private static void child(QInstance instance, String table, String label, List conditions) + { + QJoinMetaData join = new QJoinMetaData().withLeftTable(CollaborationTables.COMMENT).withRightTable(table) + .withInferredName().withType(JoinType.ONE_TO_MANY).withJoinOns(conditions); + instance.addJoin(join); + instance.addWidget(ChildRecordListRenderer.widgetMetaDataBuilder(join).withLabel(label).withCanAddChildRecord(false).getWidgetMetaData()); + instance.getTable(CollaborationTables.COMMENT).withSection(new QFieldSection(table, new QIcon("list"), Tier.T2).withLabel(label).withWidgetName(join.getName())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotification.java b/src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotification.java new file mode 100644 index 0000000..fc6b8d2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotification.java @@ -0,0 +1,38 @@ +/* + * 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.notify; + + +/******************************************************************************* + ** Transport-neutral notification. Body is markdown; link is a record href. + *******************************************************************************/ +public record CollaborationNotification(String recipientUserId, String title, String body, Category category, + String sourceTableName, String sourceRecordId, String link) +{ + /******************************************************************************* + ** Categories supported by the collaboration notification boundary. + *******************************************************************************/ + public enum Category + { + MENTION, SHARE, FOLLOW, TASK + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotifier.java b/src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotifier.java new file mode 100644 index 0000000..26bcfa1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/notify/CollaborationNotifier.java @@ -0,0 +1,34 @@ +/* + * 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.notify; + + +/******************************************************************************* + ** replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released + *******************************************************************************/ +public interface CollaborationNotifier +{ + /******************************************************************************* + ** Deliver a recipient-authorized notification. + *******************************************************************************/ + void notify(CollaborationNotification n); +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/notify/NoOpNotifier.java b/src/main/java/com/kingsrook/qbits/collaboration/notify/NoOpNotifier.java new file mode 100644 index 0000000..bbc09bf --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/notify/NoOpNotifier.java @@ -0,0 +1,37 @@ +/* + * 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.notify; + + +/******************************************************************************* + ** Default when no notification transport is installed. + *******************************************************************************/ +public class NoOpNotifier implements CollaborationNotifier +{ + /******************************************************************************* + ** Activity remains available in the timeline without a delivery transport. + *******************************************************************************/ + @Override + public void notify(CollaborationNotification n) + { + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java b/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java new file mode 100644 index 0000000..5084a0c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.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.notify; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qbits.collaboration.security.OtherUserAccess; +import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; +import com.kingsrook.qqq.backend.core.context.CapturedContext; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeVoidVoidMethod; + +/******************************************************************************* + ** Notification delivery uses RC.5 after-commit callbacks and fresh recipient access. + ** replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released + *******************************************************************************/ +public final class Notifications +{ + /******************************************************************************* + ** Static transport boundary. + *******************************************************************************/ + private Notifications() + { + } + + /******************************************************************************* + ** Capture instance/session, but never carry a completed transaction into delivery. + *******************************************************************************/ + public static void send(CollaborationNotification notification) throws QException + { + send(notification, null); + } + + /******************************************************************************* + ** Optional receipt runs only after actual transport delivery, never on rollback. + *******************************************************************************/ + public static void send(CollaborationNotification notification, UnsafeVoidVoidMethod receipt) throws QException + { + CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession()); + if(QContext.getQBackendTransaction() != null) + { + QContext.getQBackendTransaction().addAfterCommitCallback(() -> + { + try + { + QContext.withTemporaryContext(context, () -> deliver(notification, receipt)); + } + catch(QException e) + { + throw (new IllegalStateException("Collaboration notification failed after commit", e)); + } + }); + } + else + { + deliver(notification, receipt); + } + } + + /******************************************************************************* + ** Re-check immediately before exposing record data to the transport. + *******************************************************************************/ + private static void deliver(CollaborationNotification notification, UnsafeVoidVoidMethod receipt) throws QException + { + if(new OtherUserAccess().canRead(notification.recipientUserId(), notification.sourceTableName(), notification.sourceRecordId()).orElse(false)) + { + CollaborationNotifier notifier = QCodeLoader.getAdHoc(CollaborationNotifier.class, CollaborationRecords.config().getNotifier()); + notifier.notify(notification); + if(receipt != null && !(notifier instanceof NoOpNotifier)) + { + receipt.run(); + } + } + } + + /******************************************************************************* + ** Relative canonical frontend link with separately encoded path segments. + *******************************************************************************/ + public static String link(String table, String id) + { + return ("/app/" + URLEncoder.encode(table, StandardCharsets.UTF_8).replace("+", "%20") + "/" + + URLEncoder.encode(id, StandardCharsets.UTF_8).replace("+", "%20")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/notify/RecordingNotifier.java b/src/main/java/com/kingsrook/qbits/collaboration/notify/RecordingNotifier.java new file mode 100644 index 0000000..11b73db --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/notify/RecordingNotifier.java @@ -0,0 +1,50 @@ +/* + * 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.notify; + +import java.util.ArrayList; +import java.util.List; + +/******************************************************************************* + ** In-memory transport for tests; each instance owns its recorded notifications. + *******************************************************************************/ +public class RecordingNotifier implements CollaborationNotifier +{ + private final List notifications = new ArrayList<>(); + + /******************************************************************************* + ** Record delivery for assertions. + *******************************************************************************/ + @Override + public synchronized void notify(CollaborationNotification n) + { + notifications.add(n); + } + + /******************************************************************************* + ** Immutable snapshot of delivered notifications. + *******************************************************************************/ + public synchronized List notifications() + { + return (List.copyOf(notifications)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java b/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java new file mode 100644 index 0000000..4fb05d0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/people/PeopleService.java @@ -0,0 +1,162 @@ +/* + * 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.people; + +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +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.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.CriteriaOption; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Lazy, service-managed directory. Persisted timestamps make throttling survive + ** service recreation and avoid a cache claiming success after rollback. + *******************************************************************************/ +public class PeopleService +{ + private final Clock clock; + + /******************************************************************************* + ** Production clock. + *******************************************************************************/ + public PeopleService() + { + this(Clock.systemUTC()); + } + + /******************************************************************************* + ** Deterministic clock for directory maintenance tests. + *******************************************************************************/ + public PeopleService(Clock clock) + { + this.clock = clock; + } + + /******************************************************************************* + ** Upsert only the caller, at most once every ten minutes. + *******************************************************************************/ + public void touch() throws QException + { + String user = PersonalRecords.currentUserId(); + List rows = CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, user)); + Instant now = clock.instant(); + if(!rows.isEmpty() && rows.getFirst().getValueInstant("lastSeenAt") != null + && rows.getFirst().getValueInstant("lastSeenAt").plusSeconds(600).isAfter(now)) + { + return; + } + String name = QContext.getQSession().getUser().getFullName(); + QRecord row = new QRecord().withValue("userId", user).withValue("displayName", name == null || name.isBlank() ? user : name).withValue("lastSeenAt", now); + if(rows.isEmpty()) + { + try + { + CollaborationRecords.insert(CollaborationTables.USER, row.withValue("active", true)); + } + catch(QException e) + { + if(CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, user)).isEmpty()) + { + throw (e); + } + // A concurrent first call populated the same directory identity. + } + } + else + { + CollaborationRecords.update(CollaborationTables.USER, row.withValue("id", rows.getFirst().getValue("id"))); + } + } + + /******************************************************************************* + ** Directory search requires a signed-in caller and excludes inactive people. + *******************************************************************************/ + public List search(String q, Integer limit) throws QException + { + return (search(q, limit, Set.of())); + } + + /******************************************************************************* + ** Rank participants before limiting, with bounded backend queries for both groups. + *******************************************************************************/ + public List search(String q, Integer limit, Set preferredUserIds) throws QException + { + PersonalRecords.currentUserId(); + if(limit == null || limit < 1 || limit > 50) + { + throw (new QException("Limit must be between 1 and 50")); + } + List rows = new ArrayList<>(); + if(!preferredUserIds.isEmpty()) + { + rows.addAll(CollaborationRecords.query(CollaborationTables.USER, searchFilter(q, limit) + .withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferredUserIds)))); + } + if(rows.size() < limit) + { + QQueryFilter other = searchFilter(q, limit - rows.size()); + if(!preferredUserIds.isEmpty()) + { + other.withCriteria("userId", QCriteriaOperator.NOT_IN, new ArrayList<>(preferredUserIds)); + } + rows.addAll(CollaborationRecords.query(CollaborationTables.USER, other)); + } + return (rows.stream().map(row -> new PersonEntry(row.getValueString("userId"), row.getValueString("displayName"), + row.getValueString("email"), row.getValueInstant("lastSeenAt"))).toList()); + } + + /******************************************************************************* + ** Keep active and group restrictions outside the display-name/email OR. + *******************************************************************************/ + private QQueryFilter searchFilter(String q, Integer limit) + { + QQueryFilter filter = new QQueryFilter().withCriteria("active", QCriteriaOperator.EQUALS, true) + .withOrderBy(new QFilterOrderBy("displayName")).withOrderBy(new QFilterOrderBy("userId")).withLimit(limit); + if(q != null && !q.isBlank()) + { + filter.withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) + .withCriteria(new QFilterCriteria("displayName", QCriteriaOperator.CONTAINS, q).withOption(CriteriaOption.CASE_INSENSITIVE)) + .withCriteria(new QFilterCriteria("email", QCriteriaOperator.CONTAINS, q).withOption(CriteriaOption.CASE_INSENSITIVE))); + } + return (filter); + } + + /******************************************************************************* + ** Resolve display names without publishing the raw directory table. + *******************************************************************************/ + public String displayName(String userId) throws QException + { + 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")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/people/PersonEntry.java b/src/main/java/com/kingsrook/qbits/collaboration/people/PersonEntry.java new file mode 100644 index 0000000..344f995 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/people/PersonEntry.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.people; + +import java.time.Instant; + +/******************************************************************************* + ** Safe directory fields returned to mention autocomplete. + *******************************************************************************/ +public record PersonEntry(String userId, String displayName, String email, Instant lastSeenAt) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java index 890b846..19afa23 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java @@ -61,6 +61,7 @@ public final class RecentsService *******************************************************************************/ public static void recordView(String targetType, String targetName, Serializable recordId) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); String userId = PersonalRecords.currentUserId(); FavoriteEntry target = TargetResolver.require(targetType, targetName, recordId); QQueryFilter filter = PersonalRecords.targetFilter(targetType, targetName, target.recordId()); @@ -102,6 +103,7 @@ public final class RecentsService *******************************************************************************/ public static List list(Integer limit) throws QException { + new com.kingsrook.qbits.collaboration.people.PeopleService().touch(); if(limit == null || limit < 1 || limit > 50) { throw (new QException("Limit must be between 1 and 50")); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java new file mode 100644 index 0000000..f2ad757 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java @@ -0,0 +1,65 @@ +/* + * 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 com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissionChecker; +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.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; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + +/******************************************************************************* + ** Standard table/API access is read-only; service calls authorize separately. + *******************************************************************************/ +public class CollaborationRowSecurity implements CustomPermissionChecker +{ + /******************************************************************************* + ** Apply parent-table restrictions before count, query and aggregate execution. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput) + { + throw (new QPermissionDeniedException("Use the collaboration service actions.")); + } + new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata); + if(metadata instanceof QTableMetaData table + && (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/OtherUserAccess.java b/src/main/java/com/kingsrook/qbits/collaboration/security/OtherUserAccess.java new file mode 100644 index 0000000..808cebd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/OtherUserAccess.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.security; + +import java.io.Serializable; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; +import com.kingsrook.qqq.backend.core.context.CapturedContext; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.model.session.QSession; + +/******************************************************************************* + ** Stand-in for core RecordAccessCheckerInterface. Unknown is never a grant. + *******************************************************************************/ +public class OtherUserAccess +{ + /******************************************************************************* + ** 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) + { + return (Optional.empty()); + } + UserSessionSupplier supplier = QCodeLoader.getAdHoc(UserSessionSupplier.class, config.getUserSessionSupplier()); + Optional session = supplier.sessionFor(userId); + if(session.isEmpty()) + { + return (Optional.empty()); + } + if(session.get().getUser() == null || !userId.equals(session.get().getUser().getIdReference())) + { + return (Optional.empty()); + } + AtomicReference result = new AtomicReference<>(false); + QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), session.get()), () -> + { + try + { + ParentRecordAccess.requireVisibleParent(table, recordId); + result.set(true); + } + catch(QNotFoundException expected) + { + result.set(false); + } + }); + return (Optional.of(result.get())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRowCustomizer.java b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRowCustomizer.java new file mode 100644 index 0000000..b453dec --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRowCustomizer.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.security; + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; +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.QueryOrGetInputInterface; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Parent read locks also protect direct GetAction and unpinned table browsing. + ** Until core mirrors locks, aggregate counts are approximate and pages may be short. + *******************************************************************************/ +public class ParentRowCustomizer implements TableCustomizerInterface +{ + /******************************************************************************* + ** Children store the parent's reference so the same filter protects them. + *******************************************************************************/ + @Override + public List postQuery(QueryOrGetInputInterface input, List records) throws QException + { + List visible = new ArrayList<>(); + for(QRecord record : records) + { + try + { + if(Boolean.TRUE.equals(record.getValueBoolean("parentDeleted"))) + { + throw (new QNotFoundException("Not found")); + } + ParentRecordAccess.requireVisibleParent(record.getValueString("tableName"), record.getValueString("recordId")); + QRecord row = new QRecord(record); + if(row.getValue("deletedAt") != null) + { + row.setValue("body", null); + } + visible.add(row); + } + catch(QNotFoundException expected) + { + if(input instanceof GetInput) + { + throw (expected); + } + } + } + return (visible); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentTableRestriction.java b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentTableRestriction.java new file mode 100644 index 0000000..a52aeec --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentTableRestriction.java @@ -0,0 +1,58 @@ +/* + * 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 new file mode 100644 index 0000000..2c3b8b0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/UserSessionSupplier.java @@ -0,0 +1,37 @@ +/* + * 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/timeline/TimelineDetail.java b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineDetail.java new file mode 100644 index 0000000..6839a63 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineDetail.java @@ -0,0 +1,30 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.timeline; + + +/******************************************************************************* + ** Field-level audit change, omitted entirely when the field is hidden. + *******************************************************************************/ +public record TimelineDetail(String fieldName, String oldValue, String newValue, String message) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineItem.java b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineItem.java new file mode 100644 index 0000000..b5eaa9e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineItem.java @@ -0,0 +1,32 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.timeline; + +import java.time.Instant; +import java.util.List; + +/******************************************************************************* + ** A typed timeline event; core audits do not store a stable session user ID. + *******************************************************************************/ +public record TimelineItem(String kind, Long id, Instant at, String actorUserId, String actorName, String summary, List details, String refTable, String refId) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelinePage.java b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelinePage.java new file mode 100644 index 0000000..d946fea --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelinePage.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.timeline; + +import java.util.List; + +/******************************************************************************* + ** Merged events and opaque descending tuple cursor. + *******************************************************************************/ +public record TimelinePage(List items, String nextCursor) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java new file mode 100644 index 0000000..7dd794b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/timeline/TimelineService.java @@ -0,0 +1,184 @@ +/* + * 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.timeline; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qbits.collaboration.RecordCursor; +import com.kingsrook.qbits.collaboration.follow.FollowService; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.people.PeopleService; +import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerAction; +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.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.metadata.personalization.TableMetaDataPersonalizerInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource; +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.audits.AuditsMetaDataProvider; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + +/******************************************************************************* + ** Merge core audit and collaboration activity using each source's read permissions. + ** Audit details use the same personalized host fields as core's audit screen. + *******************************************************************************/ +public class TimelineService +{ + /******************************************************************************* + ** Types accepts AUDIT/ACTIVITY or individual collaboration activity type names. + *******************************************************************************/ + 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) + { + throw (new QException("Limit must be between 1 and 50")); + } + RecordCursor before = beforeCursor == null ? null : RecordCursor.decode(beforeCursor); + if(before != null && before.kind().equals("COMMENT")) + { + throw (new QException("Invalid timeline cursor")); + } + List items = new ArrayList<>(); + boolean all = types == null || types.isEmpty(); + if(all || types.contains("ACTIVITY") || types.stream().anyMatch(type -> !type.equals("AUDIT"))) + { + QQueryFilter filter = CollaborationRecords.parent(table, id).withOrderBy(new QFilterOrderBy("at", false)) + .withOrderBy(new QFilterOrderBy("id", false)).withLimit(limit + 1); + if(before != null) + { + filter.withSubFilter(before.beforeFilter("at", "ACTIVITY")); + } + if(!all && !types.contains("ACTIVITY")) + { + filter.withCriteria("type", QCriteriaOperator.IN, new ArrayList<>(types)); + } + for(QRecord row : CollaborationRecords.query(CollaborationTables.ACTIVITY, filter)) + { + items.add(new TimelineItem("ACTIVITY", row.getValueLong("id"), row.getValueInstant("at"), row.getValueString("actorUserId"), + new PeopleService().displayName(row.getValueString("actorUserId")), row.getValueString("summary"), List.of(), row.getValueString("refTableName"), row.getValueString("refId"))); + } + } + if(all || types.contains("AUDIT")) + { + items.addAll(audits(table, id, before, limit + 1)); + } + items.sort(Comparator.comparing(TimelineItem::at).thenComparing(TimelineItem::kind).thenComparing(TimelineItem::id).reversed()); + List page = items.stream().limit(limit).toList(); + String next = items.size() > limit ? key(page.getLast()).encode() : null; + return (new TimelinePage(page, next)); + } + + /******************************************************************************* + ** Missing audit installation or denied audit permission quietly omits the stream. + ** Read audit headers separately so a many-detail join never truncates an event. + *******************************************************************************/ + private List audits(String table, String id, RecordCursor before, Integer limit) throws QException + { + if(QContext.getQInstance().getTable(AuditsMetaDataProvider.TABLE_NAME_AUDIT) == null) + { + return (List.of()); + } + try + { + List tables = query(AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, new QQueryFilter().withCriteria("name", QCriteriaOperator.EQUALS, table)); + if(tables.isEmpty()) + { + return (List.of()); + } + QQueryFilter filter = new QQueryFilter().withCriteria("auditTableId", QCriteriaOperator.EQUALS, tables.getFirst().getValue("id")) + .withCriteria("recordId", QCriteriaOperator.EQUALS, id).withOrderBy(new QFilterOrderBy("timestamp", false)) + .withOrderBy(new QFilterOrderBy("id", false)).withLimit(limit); + if(before != null) + { + filter.withSubFilter(before.beforeFilter("timestamp", "AUDIT")); + } + List headers = query(AuditsMetaDataProvider.TABLE_NAME_AUDIT, filter); + if(headers.isEmpty()) + { + return (List.of()); + } + TableMetaDataPersonalizerInput personalization = new TableMetaDataPersonalizerInput().withTableName(table).withInputSource(QInputSource.USER); + personalization.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + QTableMetaData personalized = TableMetaDataPersonalizerAction.execute(personalization); + Map> details = new HashMap<>(); + for(QRecord detail : query(AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL, new QQueryFilter() + .withCriteria("auditId", QCriteriaOperator.IN, headers.stream().map(row -> row.getValue("id")).toList()) + .withOrderBy(new QFilterOrderBy("id")))) + { + String field = detail.getValueString("fieldName"); + if(field == null || field.isBlank() || (personalized != null && personalized.getFields().containsKey(field))) + { + details.computeIfAbsent(detail.getValueLong("auditId"), ignored -> new ArrayList<>()) + .add(new TimelineDetail(field, detail.getValueString("oldValue"), detail.getValueString("newValue"), detail.getValueString("message"))); + } + } + Map actors = new HashMap<>(); + for(QRecord actor : query(AuditsMetaDataProvider.TABLE_NAME_AUDIT_USER, new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, + headers.stream().map(row -> row.getValue("auditUserId")).filter(java.util.Objects::nonNull).distinct().toList()))) + { + actors.put(actor.getValueInteger("id"), actor.getValueString("name")); + } + 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"))); + } + return (result); + } + catch(QPermissionDeniedException expected) + { + return (List.of()); + } + } + + /******************************************************************************* + ** Explicit enforcement applies to every audit table, independent of host defaults. + *******************************************************************************/ + private List query(String table, QQueryFilter filter) throws QException + { + QueryInput input = new QueryInput(table).withFilter(filter); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + return (new QueryAction().execute(input).getRecords()); + } + + /******************************************************************************* + ** Stable descending key, including equal timestamps across both streams. + *******************************************************************************/ + private RecordCursor key(TimelineItem item) + { + return (new RecordCursor(item.at(), item.kind(), item.id())); + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java index 5831f89..96f1cdf 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.ATTACHMENT, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER); + .containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, 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(5); + assertThat(headless.getTables()).hasSize(11); assertThat(headless.getApps()).isEmpty(); - assertThat(headless.getWidgets()).isEmpty(); + assertThat(headless.getWidgets()).hasSize(3); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java new file mode 100644 index 0000000..f13cb2e --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java @@ -0,0 +1,530 @@ +/* + * 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.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import com.kingsrook.qbits.collaboration.comments.CommentEntry; +import com.kingsrook.qbits.collaboration.comments.CommentPage; +import com.kingsrook.qbits.collaboration.comments.CommentsService; +import com.kingsrook.qbits.collaboration.comments.MentionEntry; +import com.kingsrook.qbits.collaboration.follow.FollowChangeBatcher; +import com.kingsrook.qbits.collaboration.follow.FollowProcesses; +import com.kingsrook.qbits.collaboration.follow.FollowService; +import com.kingsrook.qbits.collaboration.follow.SendFollowDigestsProcess; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.notify.CollaborationNotification; +import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier; +import com.kingsrook.qbits.collaboration.notify.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.qqq.backend.core.actions.QBackendTransaction; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeEvent; +import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.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.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Memory-backed acceptance tests exercise real permissions, actions and callbacks. + *******************************************************************************/ +public class ConversationServicesTest extends PersonalServiceFixture +{ + private static RecordingNotifier recorder; + private static final Map sessions = new HashMap<>(); + private final CommentsService comments = new CommentsService(); + private final FollowService follows = new FollowService(); + + /******************************************************************************* + ** Isolated transport/session provider for each test. + *******************************************************************************/ + @BeforeEach + void configure() throws Exception + { + recorder = new RecordingNotifier(); + sessions.clear(); + 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)); + QContext.getQSession().getUser().setFullName("Alice Example"); + new QInstanceValidator().validate(instance); + } + + /******************************************************************************* + ** Test transport configured through the actual QCodeReference loader. + *******************************************************************************/ + public static class TestNotifier implements CollaborationNotifier + { + @Override + public void notify(CollaborationNotification n) + { + recorder.notify(n); + } + } + + /******************************************************************************* + ** The provider returns explicit test identities with real record locks. + *******************************************************************************/ + public static class TestSessions implements UserSessionSupplier + { + @Override + public Optional sessionFor(String userId) + { + return (Optional.ofNullable(sessions.get(userId))); + } + } + + /******************************************************************************* + ** Explicit read-only recipient permissions. + *******************************************************************************/ + private static QSession session(String user, String tenant) + { + return (new QSession().withUser(new QUser().withIdReference(user)).withPermissions("document.read").withSecurityKeyValue("tenant", tenant)); + } + + /******************************************************************************* + ** Mentions are recipient safe and deduplicated. + *******************************************************************************/ + @Test + void mentionsAreRecipientSafeAndDeduplicated() throws Exception + { + CommentEntry entry = comments.post("document", "01", "Hi @[Bob](user:bob) @[Again](user:bob) @[Eve](user:eve) @[Unknown](user:missing)", null, "title"); + assertThat(entry.authorDisplayName()).isEqualTo("Alice Example"); + assertThat(entry.canEdit()).isTrue(); + assertThat(entry.canDelete()).isTrue(); + assertThat(entry.mentions()).containsExactly(new MentionEntry("bob", true), new MentionEntry("eve", false), new MentionEntry("missing", false)); + assertThat(recorder.notifications()).singleElement().satisfies(n -> + { + assertThat(n.recipientUserId()).isEqualTo("bob"); + assertThat(n.category()).isEqualTo(CollaborationNotification.Category.MENTION); + assertThat(n.link()).isEqualTo("/app/document/1"); + assertThat(n.sourceRecordId()).isEqualTo("1"); + }); + assertThat(CollaborationRecords.query(CollaborationTables.MENTION, new QQueryFilter().withCriteria("mentionedUserId", QCriteriaOperator.EQUALS, "bob"))) + .singleElement().satisfies(mention -> assertThat(mention.getValueInstant("notifiedAt")).isNotNull()); + assertThat(follows.followers("document", 1)).extracting(f -> f.userId()).containsExactlyInAnyOrder("alice", "bob"); + assertThat(follows.followers("document", 1)).allSatisfy(f -> + { + assertThat(f.notifyOnComment()).isTrue(); + assertThat(f.notifyOnChange()).isFalse(); + }); + comments.edit(entry.id(), "Again @[Bob](user:bob)"); + assertThat(recorder.notifications()).hasSize(1); + config.withUserSessionSupplier(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(); + } + + /******************************************************************************* + ** Transactions defer delivery and recheck access. + *******************************************************************************/ + @Test + void transactionsDeferDeliveryAndRecheckAccess() throws Exception + { + QBackendTransaction tx = new QBackendTransaction(); + QContext.setTransaction(tx); + comments.post("document", 1, "@[Bob](user:bob)", null, null); + assertThat(recorder.notifications()).isEmpty(); + assertThat(CollaborationRecords.query(CollaborationTables.MENTION, new QQueryFilter()).getFirst().getValueInstant("notifiedAt")).isNull(); + tx.commit(); + assertThat(recorder.notifications()).hasSize(1); + assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo("alice"); + assertThat(QContext.getQBackendTransaction()).isSameAs(tx); + comments.post("document", 1, "Another @[Bob](user:bob)", null, null); + tx.rollback(); + assertThat(recorder.notifications()).hasSize(1); + comments.post("document", 1, "Revoked @[Bob](user:bob)", null, null); + sessions.put("bob", session("bob", "b")); + tx.commit(); + assertThat(recorder.notifications()).hasSize(1); + QContext.clearTransaction(); + sessions.put("bob", session("wrong-user", "a")); + assertThat(new OtherUserAccess().canRead("bob", "document", 1)).isEmpty(); + new NoOpNotifier().notify(recorder.notifications().getFirst()); + } + + /******************************************************************************* + ** Replies history and resolution. + *******************************************************************************/ + @Test + void repliesHistoryAndResolution() throws Exception + { + CommentEntry root = comments.post("document", 1, "Root", null, null); + CommentEntry reply = comments.post("document", 1, "Reply", root.id(), null); + CommentEntry nested = comments.post("document", 1, "Flattened", reply.id(), null); + assertThat(nested.threadId()).isEqualTo(root.id()); + assertThat(nested.parentCommentId()).isEqualTo(root.id()); + CommentEntry edited = comments.edit(reply.id(), "Changed @[Bob](user:bob)"); + assertThat(edited.editedAt()).isNotNull(); + assertThat(comments.history(reply.id())).singleElement().satisfies(revision -> + { + assertThat(revision.body()).isEqualTo("Reply"); + assertThat(revision.editedByUserId()).isEqualTo("alice"); + }); + comments.resolve(root.id()); + assertThat(comments.list("document", 1, null, 20).threads().getFirst().resolved()).isTrue(); + comments.reopen(root.id()); + CommentEntry thread = comments.list("document", 1, null, 20).threads().getFirst(); + assertThat(thread.resolved()).isFalse(); + assertThat(thread.replies()).extracting(CommentEntry::id).containsExactly(reply.id(), nested.id()); + assertThatThrownBy(() -> comments.resolve(reply.id())).hasMessageContaining("root"); + assertThatThrownBy(() -> comments.post("document", 2, "Wrong parent", root.id(), null)).isInstanceOf(QNotFoundException.class); + } + + /******************************************************************************* + ** Own delete others and moderation. + *******************************************************************************/ + @Test + void ownDeleteOthersAndModeration() throws Exception + { + CommentEntry row = comments.post("document", 1, "Delete me", null, null); + login("bob"); + assertThat(comments.history(row.id())).isEmpty(); + assertThatThrownBy(() -> comments.delete(row.id())).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> comments.edit(row.id(), "rewrite")).isInstanceOf(QPermissionDeniedException.class); + admin(); + assertThatThrownBy(() -> comments.edit(row.id(), "moderated rewrite")).isInstanceOf(QPermissionDeniedException.class); + comments.delete(row.id()); + assertThat(comments.list("document", 1, null, 20).threads()).singleElement().satisfies(deleted -> + { + assertThat(deleted.body()).isNull(); + assertThat(deleted.deleted()).isTrue(); + assertThat(deleted.canEdit()).isFalse(); + }); + login("alice"); + CommentEntry mine = comments.post("document", 1, "Own", null, null); + comments.delete(mine.id()); + assertThatThrownBy(() -> comments.edit(mine.id(), "undelete")).isInstanceOf(QPermissionDeniedException.class); + } + + /******************************************************************************* + ** Parent and feature permissions. + *******************************************************************************/ + @Test + void parentAndFeaturePermissions() throws Exception + { + assertThatThrownBy(() -> comments.post("document", 3, "secret", null, null)).hasMessage("Not found"); + assertThatThrownBy(() -> comments.list("document", 3, null, 20)).hasMessage("Not found"); + QContext.getQSession().getPermissions().remove("document.write"); + assertThatThrownBy(() -> comments.post("document", 1, "read only", null, null)).isInstanceOf(QPermissionDeniedException.class); + QContext.getQSession().withPermissions("document.comment"); + CommentEntry row = comments.post("document", 1, "explicit grant", null, null); + QContext.getQSession().getPermissions().remove("document.comment"); + assertThat(comments.list("document", 1, null, 20).threads().getFirst().canEdit()).isFalse(); + assertThatThrownBy(() -> comments.edit(row.id(), "denied")).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> comments.resolve(row.id())).isInstanceOf(QPermissionDeniedException.class); + assertThat(follows.follow("document", 1, null, null).notifyOnChange()).isFalse(); + assertThatThrownBy(() -> follows.follow("document", 3, null, null)).hasMessage("Not found"); + } + + /******************************************************************************* + ** Thread keyset paging uses latest activity. + *******************************************************************************/ + @Test + void threadKeysetPagingUsesLatestActivity() throws Exception + { + CommentEntry first = comments.post("document", 1, "first", null, null); + CommentEntry second = comments.post("document", 1, "second", null, null); + Instant tie = Instant.parse("2026-01-01T00:00:00Z"); + for(CommentEntry row : List.of(first, second)) + { + CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.id()).withValue("lastActivityAt", tie)); + } + CommentPage page = comments.list("document", 1, null, 1); + assertThat(page.threads()).extracting(CommentEntry::id).containsExactly(second.id()); + CommentPage next = comments.list("document", 1, page.nextCursor(), 1); + assertThat(next.threads()).extracting(CommentEntry::id).containsExactly(first.id()); + assertThat(next.nextCursor()).isNull(); + 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.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"); + String mentions = java.util.stream.IntStream.range(0, 51).mapToObj(i -> "@[P](user:u" + i + ")").collect(java.util.stream.Collectors.joining()); + assertThatThrownBy(() -> comments.post("document", 1, mentions, null, null)).hasMessageContaining("50 users"); + } + + /******************************************************************************* + ** Follow defaults mute and unfollow. + *******************************************************************************/ + @Test + void followDefaultsMuteAndUnfollow() throws Exception + { + assertThat(follows.state("document", 1)).isEmpty(); + assertThat(follows.follow("document", 1, null, null)).satisfies(f -> + { + assertThat(f.reason()).isEqualTo("MANUAL"); + assertThat(f.notifyOnChange()).isTrue(); + assertThat(f.notifyOnComment()).isTrue(); + }); + follows.follow("document", 1, false, false); + assertThat(follows.state("document", 1).orElseThrow().notifyOnChange()).isFalse(); + follows.ensureFollower("bob", "document", 1, "ASSIGNED"); + assertThat(follows.followers("document", 1)).anySatisfy(f -> + { + assertThat(f.userId()).isEqualTo("bob"); + assertThat(f.notifyOnChange()).isTrue(); + }); + QRecord bob = CollaborationRecords.query(CollaborationTables.FOLLOW, new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, "bob")).getFirst(); + CollaborationRecords.update(CollaborationTables.FOLLOW, new QRecord().withValue("id", bob.getValue("id")).withValue("mutedUntil", Instant.now().plusSeconds(600))); + comments.post("document", 1, "Muted", null, null); + assertThat(recorder.notifications()).isEmpty(); + CollaborationRecords.update(CollaborationTables.FOLLOW, new QRecord().withValue("id", bob.getValue("id")).withValue("mutedUntil", Instant.EPOCH)); + comments.post("document", 1, "Unmuted", null, null); + assertThat(recorder.notifications()).singleElement().satisfies(n -> assertThat(n.category()).isEqualTo(CollaborationNotification.Category.FOLLOW)); + follows.unfollow("document", 1); + assertThat(follows.state("document", 1)).isEmpty(); + follows.unfollow("document", 1); + } + + /******************************************************************************* + ** People throttle search and mention ranking. + *******************************************************************************/ + @Test + void peopleThrottleSearchAndMentionRanking() throws Exception + { + Instant at = Instant.parse("2026-01-01T00:00:00Z"); + 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); + login("bob"); + new PeopleService().touch(); + follows.follow("document", 1, null, null); + login("eve"); + new PeopleService().touch(); + login("alice"); + QRecord eve = CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, "eve")).getFirst(); + CollaborationRecords.update(CollaborationTables.USER, new QRecord().withValue("id", eve.getValue("id")).withValue("email", "search@example.invalid")); + assertThat(new PeopleService().search("EXAMPLE.INVALID", 50)).singleElement().satisfies(p -> assertThat(p.userId()).isEqualTo("eve")); + assertThat(comments.mentionCandidates("document", 1, "").getFirst().person().userId()).isEqualTo("bob"); + 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"); + } + + /******************************************************************************* + ** Direct writes and parent read restrictions. + *******************************************************************************/ + @Test + void directWritesAndParentReadRestrictions() throws Exception + { + CommentEntry row = comments.post("document", 1, "body", null, null); + for(String table : List.of(CollaborationTables.COMMENT, CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION, CollaborationTables.FOLLOW, CollaborationTables.ACTIVITY)) + { + assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(table).withRecords(List.of(new QRecord())))).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(table).withRecords(List.of(new QRecord().withValue("id", 1))))).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(table).withPrimaryKeys(List.of(1)))).isInstanceOf(QPermissionDeniedException.class); + } + login("viewer", CollaborationPermissions.VIEW); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.COMMENT)).getRecords()).hasSize(1); + QContext.getQSession().getSecurityKeyValues().clear(); + QContext.getQSession().withSecurityKeyValue("tenant", "b"); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.COMMENT)).getRecords()).isEmpty(); + assertThat(new GetAction().execute(new GetInput(CollaborationTables.COMMENT).withPrimaryKey(row.id())).getRecord()).isNull(); + assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.FOLLOW))).isInstanceOf(QPermissionDeniedException.class); + QContext.getQSession().getPermissions().remove("document.read"); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.COMMENT)).getRecords()).isEmpty(); + } + + /******************************************************************************* + ** Batcher sends one window skips actor and rechecks access. + *******************************************************************************/ + @Test + void batcherSendsOneWindowSkipsActorAndRechecksAccess() throws Exception + { + follows.follow("document", 1, true, true); + follows.ensureFollower("bob", "document", 1, "ASSIGNED"); + Instant at = Instant.parse("2026-01-01T00:00:00Z"); + FollowChangeBatcher batcher = new FollowChangeBatcher(Clock.fixed(at, ZoneOffset.UTC)); + batcher.recordChange("document", "1", "alice", Set.of("title")); + batcher.recordChange("document", "1", "alice", Set.of("tenant")); + assertThat(CollaborationRecords.query(CollaborationTables.FOLLOW_DIGEST, new QQueryFilter())).hasSize(1); + admin(); + new FollowChangeBatcher(Clock.fixed(at.plusSeconds(299), ZoneOffset.UTC)).sendDue(); + assertThat(recorder.notifications()).isEmpty(); + new FollowChangeBatcher(Clock.fixed(at.plusSeconds(300), ZoneOffset.UTC)).sendDue(); + assertThat(recorder.notifications()).singleElement().satisfies(n -> + { + assertThat(n.recipientUserId()).isEqualTo("bob"); + assertThat(n.body()).contains("title", "tenant"); + }); + new FollowChangeBatcher(Clock.fixed(at.plusSeconds(301), ZoneOffset.UTC)).sendDue(); + assertThat(recorder.notifications()).hasSize(1); + batcher.recordChange("document", "1", "alice", Set.of("title")); + sessions.put("bob", session("bob", "b")); + new FollowChangeBatcher(Clock.fixed(at.plusSeconds(600), ZoneOffset.UTC)).sendDue(); + assertThat(recorder.notifications()).hasSize(1); + assertThat(CollaborationRecords.query(CollaborationTables.FOLLOW_DIGEST, new QQueryFilter())).isEmpty(); + } + + /******************************************************************************* + ** Released listener defers until commit. + *******************************************************************************/ + @Test + void releasedListenerDefersUntilCommit() throws Exception + { + config.withFollowChangeTables(List.of("document")); + instance.withRecordChangeListener(new QCodeReference(FollowChangeBatcher.class)); + follows.ensureFollower("bob", "document", 1, "ASSIGNED"); + QBackendTransaction tx = new QBackendTransaction(); + new UpdateAction().execute(new UpdateInput("document").withTransaction(tx).withRecords(List.of(new QRecord().withValue("id", 1).withValue("title", "Updated")))); + assertThat(CollaborationRecords.query(CollaborationTables.FOLLOW_DIGEST, new QQueryFilter())).isEmpty(); + tx.commit(); + assertThat(CollaborationRecords.query(CollaborationTables.FOLLOW_DIGEST, new QQueryFilter())).hasSize(1); + assertThat(new FollowChangeBatcher().appliesTo("document", RecordChangeType.DELETE)).isFalse(); + assertThat(new FollowChangeBatcher().appliesTo("other", RecordChangeType.UPDATE)).isFalse(); + assertThatThrownBy(() -> new SendFollowDigestsProcess().run(null, null)).isInstanceOf(QPermissionDeniedException.class); + admin(); + new SendFollowDigestsProcess().run(null, null); + } + + /******************************************************************************* + ** Bulk follow review and result. + *******************************************************************************/ + @Test + @SuppressWarnings("unchecked") + void bulkFollowReviewAndResult() throws Exception + { + FollowProcesses.processesFor("document").forEach(instance::addProcess); + new QInstanceValidator().validate(instance); + for(String name : List.of("collaborationFollow_document", "collaborationUnfollow_document")) + { + RunProcessInput input = new RunProcessInput(); + input.setProcessName(name); + input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK); + input.addValue(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, "document"); + input.addValue(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, "document"); + input.addValue(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + input.addValue("recordsParam", "recordIds"); + input.addValue("recordIds", "1,2"); + RunProcessOutput review = new RunProcessAction().execute(input); + assertThat(review.getException()).isEmpty(); + List lines = (List) review.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY); + assertThat(lines).anySatisfy(line -> assertThat(line.getCount()).isEqualTo(2)); + RunProcessInput go = new RunProcessInput(); + go.setProcessName(name); + go.setProcessUUID(review.getProcessUUID()); + go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW); + go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + RunProcessOutput result = new RunProcessAction().execute(go); + assertThat((List) result.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY)).anySatisfy(line -> assertThat(line.getCount()).isEqualTo(2)); + assertThat(follows.state("document", 1).isPresent()).isEqualTo(name.contains("Follow_")); + } + } + + /******************************************************************************* + ** Metadata schema and real home counts. + *******************************************************************************/ + @Test + void metadataSchemaAndRealHomeCounts() throws Exception + { + var installed = new com.kingsrook.qqq.backend.core.model.metadata.QInstance() + .withInstanceDefaultAuthentication(new com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData().withName("anonymous").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.FULLY_ANONYMOUS)); + installed.addBackend(instance.getBackend("memory")); + installed.addTable(instance.getTable("document").clone()); + installed.addSecurityKeyType(instance.getSecurityKeyType("tenant")); + installed.addScheduler(new com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData().withName("minute")); + CollaborationQBitConfig configured = new CollaborationQBitConfig().withBackendName("memory").withFollowActionTables(List.of("document")) + .withFollowChangeTables(List.of("document")).withSchedulerName("minute"); + 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(1); + assertThat(installed.getProcess(SendFollowDigestsProcess.NAME).getSchedule().getRepeatSeconds()).isEqualTo(60); + assertThat(installed.getProcess(SendFollowDigestsProcess.NAME).getIsHidden()).isTrue(); + String sql = CollaborationSchema.postgresScript(config); + for(String table : List.of(CollaborationTables.COMMENT, CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION, + CollaborationTables.FOLLOW, CollaborationTables.ACTIVITY, CollaborationTables.FOLLOW_DIGEST)) + { + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS " + table); + } + 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"); + assertThatThrownBy(() -> config.withFollowActionTables(List.of("missing")).validate(instance)).hasMessageContaining("host table"); + } + + /******************************************************************************* + ** Participant ranking precedes the search limit. + *******************************************************************************/ + @Test + void participantRankingPrecedesTheSearchLimit() throws Exception + { + for(int i = 0; i < 55; i++) + { + CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "directory" + i) + .withValue("displayName", "A Person " + i).withValue("active", true)); + } + login("bob"); + QContext.getQSession().getUser().setFullName("Z Bob"); + follows.follow("document", 1, null, null); + login("alice"); + assertThat(comments.mentionCandidates("document", 1, "")).hasSize(50).first().satisfies(candidate -> assertThat(candidate.person().userId()).isEqualTo("bob")); + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.java new file mode 100644 index 0000000..db5a153 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/TimelineServiceTest.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; + +import java.time.Instant; +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.TimelineItem; +import com.kingsrook.qbits.collaboration.timeline.TimelinePage; +import com.kingsrook.qbits.collaboration.timeline.TimelineService; +import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerInterface; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.metadata.personalization.TableMetaDataPersonalizerInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +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.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +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.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Core audit schemas from RC.5, merged with real activity and permission checks. + *******************************************************************************/ +public class TimelineServiceTest extends PersonalServiceFixture +{ + private final TimelineService timeline = new TimelineService(); + private final Instant at = Instant.parse("2026-01-01T00:00:00Z"); + + /******************************************************************************* + ** Audits. + *******************************************************************************/ + @BeforeEach + void audits() throws Exception + { + new AuditsMetaDataProvider().withRecordIdType(QFieldType.STRING).defineAll(instance, "memory", table -> + table.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))); + new QInstanceValidator().validate(instance); + seed("auditTable", new QRecord().withValue("id", 1).withValue("name", "document").withValue("label", "Documents")); + seed("auditUser", new QRecord().withValue("id", 1).withValue("name", "Audit Actor")); + for(long id = 1; id <= 3; id++) + { + seed("audit", new QRecord().withValue("id", id).withValue("auditTableId", 1).withValue("auditUserId", 1) + .withValue("recordId", "1").withValue("timestamp", id == 3 ? at.plusSeconds(10) : at).withValue("message", "Record changed")); + } + seed("auditDetail", new QRecord().withValue("id", 1L).withValue("auditId", 2L).withValue("fieldName", "title").withValue("oldValue", "Old").withValue("newValue", "New")); + seed("auditDetail", new QRecord().withValue("id", 2L).withValue("auditId", 2L).withValue("fieldName", "tenant").withValue("oldValue", "sensitive old").withValue("newValue", "sensitive new")); + for(int id = 1; id <= 3; id++) + { + CollaborationRecords.insert(CollaborationTables.ACTIVITY, new QRecord().withValue("id", id).withValue("tableName", "document").withValue("recordId", "1") + .withValue("at", id == 3 ? at.minusSeconds(10) : at).withValue("actorUserId", "alice").withValue("type", "COMMENTED") + .withValue("summary", "Comment added").withValue("refTableName", CollaborationTables.COMMENT).withValue("refId", "1").withValue("parentDeleted", false)); + } + QContext.getQSession().withPermissions("audit.read", "auditTable.read", "auditDetail.read", "auditUser.read"); + } + + /******************************************************************************* + ** Seed audit-only fixture data through the memory backend, which core owns. + *******************************************************************************/ + private void seed(String table, QRecord record) throws Exception + { + MemoryRecordStore.getInstance().insert(new InsertInput(table).withRecords(List.of(record)), true); + } + + /******************************************************************************* + ** Merges ties and keyset pages without gaps or duplicates. + *******************************************************************************/ + @Test + void mergesTiesAndKeysetPagesWithoutGapsOrDuplicates() throws Exception + { + TimelinePage first = timeline.timeline("document", 1, null, 2, null); + assertThat(first.items()).extracting(TimelineItem::kind).containsExactly("AUDIT", "AUDIT"); + assertThat(first.items().get(1).details()).hasSize(2); + assertThat(first.items().get(1).actorName()).isEqualTo("Audit Actor"); + assertThat(first.items().get(1).actorUserId()).isNull(); + List seen = new ArrayList<>(); + String cursor = null; + do + { + TimelinePage page = timeline.timeline("document", 1, cursor, 1, Set.of()); + page.items().forEach(item -> seen.add(item.kind() + ":" + item.id())); + cursor = page.nextCursor(); + } + while(cursor != null && seen.size() < 10); + assertThat(seen).containsExactly("AUDIT:3", "AUDIT:2", "AUDIT:1", "ACTIVITY:2", "ACTIVITY:1", "ACTIVITY:3"); + assertThat(timeline.timeline("document", 1, null, 50, Set.of("COMMENTED")).items()).hasSize(3); + assertThat(timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items()).hasSize(3); + assertThat(timeline.timeline("document", 2, null, 50, null).items()).isEmpty(); + } + + /******************************************************************************* + ** Audit read permission and hidden fields apply. + *******************************************************************************/ + @Test + void auditReadPermissionAndHiddenFieldsApply() throws Exception + { + instance.addSupplementalCustomizer(TableMetaDataPersonalizerInterface.CUSTOMIZER_TYPE, new QCodeReference(HideTenant.class)); + TimelinePage page = timeline.timeline("document", 1, null, 50, null); + assertThat(page.items().stream().flatMap(item -> item.details().stream())).singleElement().satisfies(detail -> + { + assertThat(detail.fieldName()).isEqualTo("title"); + assertThat(detail.oldValue()).isEqualTo("Old"); + }); + QContext.getQSession().getPermissions().remove("audit.read"); + instance.setEnforcePermissionsInCoreActions(false); + 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"); + instance.getTables().remove("audit"); + assertThat(timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items()).isEmpty(); + } + + /******************************************************************************* + ** Real personalizer boundary, like core's audit screen. + *******************************************************************************/ + public static class HideTenant implements TableMetaDataPersonalizerInterface + { + @Override + public QTableMetaData execute(TableMetaDataPersonalizerInput input) + { + QTableMetaData result = input.getTable().clone(); + if(result.getName().equals("document")) + { + result.getFields().remove("tenant"); + } + return (result); + } + } +} -- 2.54.0