Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88bff675e9
|
||
|
|
0b19f956a2 | ||
|
|
22c9ec22fa
|
||
|
|
003384abd4 | ||
|
|
696f4f48ce
|
||
|
|
76e4944919 | ||
|
|
711baede51
|
||
|
|
4898e78c5d | ||
|
|
1e750b9dc6 |
No files matched your search
+20
-1
@@ -6,6 +6,25 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
### Added
|
||||
|
||||
- Phase-h links, share previews, recipient-safe notifications and SHARED followers; access requests with requester/admin locks, duplicate OPEN handling, optional permission-holder directory and streamed Mark granted/Decline outcomes.
|
||||
- Phase-i tasks with assignee validation, injectable-clock reminder/overdue receipts, complete/reopen/cancel, due-date cursor paging and streamed task actions.
|
||||
- Qbit owner/assignee services, configured Assign to/Take ownership bulk actions, My work summaries and mention read receipts.
|
||||
- Four audited tables, task-title search metadata, work/share home cards, committed parent-delete cleanup, PostgreSQL tables/indexes and a mention readAt migration.
|
||||
- Acceptance tests for security, notifications, request existence neutrality, actual streamed actions, reminders, summaries, cleanup and schema. Existing JaCoCo gates retained; UI API endpoints remain separate.
|
||||
|
||||
- Phase-d tag vocabulary and record assignments on QQQ 4.1.0-RC.5; normalized names, FREE/APPROVED_ONLY policy resolved from system-read scoped feature settings, explicit global/manage permissions, parent-checked idempotent assignment, usage counts and TAGGED/UNTAGGED activity without follower notifications.
|
||||
- Tags navigation, secured Record Tags child list, FIELD audit, label search, approved/scoped table PVS, streamed Approve/Merge/Delete and configured host Add/Remove actions, parent cleanup and real Tags home metrics. Bulk multi-select intent is exposed in picker metadata for the separate Next UI implementation.
|
||||
- Standalone `TagFilterTranslator` for IN/CONTAINS_ALL/NOT_IN/IS_BLANK/IS_NOT_BLANK with a 10,000 matching-ID cap. Filter/quick-search integration lands after QRun-IO/qqq#1140 is released.
|
||||
- Tag service, security, vocabulary, settings, schema, cap and streamed Review/Result tests; existing JaCoCo gates retained.
|
||||
|
||||
- Phase-e comments/mentions, one-level threads, edit revisions, author/admin soft deletion, resolve/reopen, ranked mention candidates and keyset pagination on QQQ 4.1.0-RC.5.
|
||||
- Comments, Comment History, Mentions, Followers, Activity and durable Follow Digests tables, child-list widgets, service-only writes, shared parent-table query restrictions, schema indexes and real home counts.
|
||||
- Lazy people-directory upserts with ten-minute throttling from comments/follow/favorites/recents/attachments entry points; bounded active-name/email search.
|
||||
- Configurable notifier and recipient-session stand-ins, default no-op and recording transports, conservative unknown-access handling and RC.5 after-commit delivery with fresh access checks.
|
||||
- Follow preferences, generic streamed ETL Follow/Unfollow actions, configured RC.5 change listeners and a hidden minute scheduler for five-minute digests.
|
||||
- Timeline merging enforced core audit reads with collaboration activity, field-visibility filtering and tuple cursors. Attachments now record FILE_ADDED/FILE_REMOVED activity.
|
||||
- Memory-backed tests for recipient access, transactions, moderation, pagination, real ETL Review/Result, digest batching, directory ranking, schema and audit timeline security. Existing JaCoCo gates retained; 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 +37,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.
|
||||
@@ -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. Phase d adds tags, vocabulary policies, management and bulk actions, and standalone filter translation. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g remain planned; general runtime overrides are still deferred. TAGS mode settings are read directly by the service.
|
||||
|
||||
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,29 @@ Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation
|
||||
| `collaboration_user` (People) | Unique session user ID, display name, email, avatar URL, last seen timestamp and active flag. Service-managed, read-only to users. |
|
||||
| `collaboration_favorite` (Favorites) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `sortOrder`, optional `folder`. Unique on user + type + name + record ID. Owner-locked, audit off. |
|
||||
| `collaboration_recent` (Recently Viewed) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `lastViewedAt`, `viewCount`. Same unique key and owner lock; audit off. |
|
||||
|
||||
| `collaboration_attachment` (Attachments) | Parent table and record ID, file metadata, SHA-256, bucket/key, optional description/thumbnail key, author and parent-deleted flag. FIELD audit; parent-based security. Preview/download URLs are virtual, never stored. |
|
||||
|
||||
All five include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold.
|
||||
| `collaboration_comment` (Comments) | Markdown body, author, root/reply reference, optional field anchor, latest thread activity, edit/resolve/delete timestamps. FIELD audit and body search. |
|
||||
| `collaboration_comment_revision` (Comment History) | Prior body, edit timestamp and editor; comment child list. |
|
||||
| `collaboration_mention` (Mentions) | Unique comment + mentioned user, recipient visibility and actual delivery timestamp; comment child list. |
|
||||
| `collaboration_follow` (Followers) | Unique parent + user, reason, change/comment notification preferences and mute timestamp. Admin browse only; Followers child list on Comments. |
|
||||
| `collaboration_activity` (Activity) | Event time, actor, type, summary and reference. Read-only admin main table. |
|
||||
| `collaboration_share` (Shares) | Recipient, sender, message, access outcome and sent/opened times. Parent security and FIELD audit. |
|
||||
| `collaboration_access_request` (Access Requests) | Requester/admin visibility, optional share reference, OPEN/GRANTED/DECLINED/WITHDRAWN status and outcome fields. FIELD audit; no parent-data lookup. |
|
||||
| `collaboration_task` (Tasks) | Title, markdown notes, assignee, due/reminder/delivery times and completion fields. Parent security, FIELD audit and title search metadata. |
|
||||
| `collaboration_record_assignment` (Record Owners) | Unique parent reference with owner/assignee. Admin-only nav with HIDDEN denial, parent security and FIELD audit. |
|
||||
| `collaboration_follow_digest` (Follow Digests) | Service-managed pending five-minute batches, unique parent + recipient, due timestamp and changed-field summaries. Admin browse only, outside navigation. |
|
||||
|
||||
The home reports persisted enabled settings by scope type, people known and people seen within the inclusive last seven days, plus Favorites and Recently viewed row counts and distinct user counts, and attachment count and total bytes. Attachment aggregates are explicitly approximate until core can mirror parent locks. Recent activity is based on `lastSeenAt`, independently of the directory's `active` account flag. Empty states explain missing data. These counts do not represent resolved feature enablement on host tables.
|
||||
All tables include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold.
|
||||
|
||||
The home 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 +98,12 @@ List<FavoriteEntry> recents = RecentsService.list(50);
|
||||
|
||||
- Star and unstar are idempotent. New stars append to the existing sort order. Reorder moves the supplied IDs first and preserves the order of omitted favorites; folder entries may be null to clear a folder. All supplied IDs must belong to the current user.
|
||||
- Targets are checked using core table/app/process/report permissions; record targets use `GetAction` and the session's record locks. Invisible and missing targets both fail with `Not found`.
|
||||
- Lists refresh labels, using one batched record query per target table per batch. Favorites retain unavailable entries as `available=false`, `label="Unavailable"`, `href=null`; the cached label is never returned. Unstar remains possible after access is lost. Recents drop unavailable entries and refill from older rows up to the requested limit (1–50).
|
||||
- Lists refresh labels, using one batched record query per target table per batch. Favorites retain unavailable entries as `available=false`, `label="Unavailable"`, `href=null`; the cached label is never returned. Unstar remains possible after access is lost. Recents drop unavailable entries and refill from older rows up to the requested limit (1–50).
|
||||
- `FavoriteEntry` contains `id`, `targetType`, `targetName`, `recordId`, `label`, `folder`, `sortOrder`, `available` and `href`. Recents use the same target DTO in newest-first order; their folder and sort order are null. `viewCount` and `lastViewedAt` remain available on the underlying recent table.
|
||||
- Hrefs mirror Next's accepted flat routes: `/app/<table>/`, `/app/<table>/<id>/`, `/app/<app-or-process-or-report>/`, and `/app/<table>/savedView/<id>/`. Segments are URL-encoded. This avoids reconstructing nested app paths; trailing slashes support dotted process names.
|
||||
- Record views upsert a unique personal target. If its persisted `lastViewedAt` is less than 60 seconds ago, nothing is written; otherwise its timestamp, cached label and count update. The hidden backend-only `collaborationTrimRecents` process retains the newest `recentsPerUser` targets independently for each user, breaking timestamp ties by ID.
|
||||
|
||||
The UI API endpoints in spec §10.2 will arrive in a **follow-up PR once QQQ's UI API extension point ([QRun-IO/qqq#1128](https://github.com/QRun-IO/qqq/pull/1128)) is released**. This PR adds no endpoint or unreleased core dependency. Runtime feature overrides and the record-feature summary SPI also await released core contracts; the home counts are implemented now.
|
||||
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 +133,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 `<table>.comment` or table EDIT). Markdown is nonblank and at most 20,000 characters; clients must sanitize rendered HTML. Replies to replies attach to the root. Roots have null `threadId`; replies store the root ID in both thread/parent fields. Deleted DTO bodies are null. Authors alone edit; authors with comment permission or admins delete. Resolve/reopen applies only to roots. History follows parent READ.
|
||||
- Mentions use `@[Name](user:<userId>)`, deduplicate IDs and reject more than 50. `OtherUserAccess.canRead` returns `Optional<Boolean>` using a supplied recipient session and an enforced GetAction in a temporary context. Missing providers/sessions and mismatched identities are unknown. False/unknown recipients get neither record data nor automatic follows. Edits notify only previously unmentioned users. Mention delivery and ordinary follower delivery are deduplicated; authors never receive their own comment notifications.
|
||||
- `CommentEntry` includes author display name, capabilities, deleted/resolved state, mentions with visibility, and ordered replies. `CommentPage` pages 1–50 root threads by latest activity then ID, with replies oldest first. Pass `nextCursor` unchanged. Cursors are opaque, versioned Base64 tuple encodings, not authorization tokens.
|
||||
- Manual/assigned follow creation defaults change notifications on; other reasons default them off. Comment notifications default on. Existing preferences survive automatic follows and repeated calls; non-null arguments to `follow` change preferences. Follow needs only parent READ. The internal `ensureFollower` helper also verifies other-user access. Muted subscriptions are skipped until `mutedUntil` expires.
|
||||
- `FollowProcesses.processesFor(tableName)` returns the same Follow/Unfollow processes for manual host registration. Review performs no writes; Result rechecks permissions and reports successes/failures. They work with the host's actual primary-key field, not just `id`, and are never navigation entries.
|
||||
- RC.5 has `RecordChangeListenerInterface` and `QBackendTransaction.addAfterCommitCallback`. The configured UPDATE listener queues changed-field summaries after commit, excluding the actor's own subscription. The trusted `FollowChangeBatcher.recordChange` helper may also be called by host code after its write commits. `collaborationSendFollowDigests` runs every minute and sends batches due five minutes after their first change, rechecking preferences, mute and recipient access. Pending data survives process restarts. Run one digest scheduler per installation; local synchronization is not a distributed lock. A transport crash after delivery but before row deletion can cause a retry; an adapter needing exactly-once delivery must provide idempotency.
|
||||
- Notifications carry recipient ID, title, markdown body, MENTION/SHARE/FOLLOW/TASK category, source table/record and `/app/<table>/<id>` href. Without a transport, `NoOpNotifier` sends nothing and activity remains available. With a `QContext` transaction, delivery waits for commit and is discarded on rollback; otherwise it follows the write immediately. Access is checked again at delivery. Mention `notifiedAt` is recorded only after a real transport returns successfully. `RecordingNotifier` provides an in-memory test sink.
|
||||
- Timeline merges collaboration activity and RC.5 `audit`/`auditDetail`/`auditTable`/`auditUser` through enforced QueryActions. Missing or denied audit access omits that stream. Audit details exclude fields absent from the caller's personalized table. Core audits have actor names but no session user IDs, so their `actorUserId` is null. Paging uses `(at, kind, id)`; `types` accepts AUDIT, ACTIVITY or individual activity type names. Each source query is bounded before merging.
|
||||
- `PeopleService.touch()` upserts the signed-in user's full name and last-seen timestamp at most once per ten minutes, using the persisted timestamp. Comments/follow and existing favorites/recents/attachment entry points call it. `search(q, limit)` matches active users' names/emails case-insensitively. QUser in RC.5 has no email/avatar fields; trusted directory integrations can enrich those columns. Mention candidates rank followers/commenters before applying the 50-result limit.
|
||||
|
||||
The main comments/activity tables, revision/mention child joins and Followers child list are ordinary QQQ metadata. Direct API writes are refused; services authorize the actual parent. Shared `ParentTableRestriction` protects attachment and conversation queries/counts/aggregates; parent post-filtering protects row reads. Aggregate counts remain approximate and browse pages can be short for unmirrored parent locks. Raw persistence, activity writing and change-hook helpers are trusted internal boundaries and must not become public endpoints.
|
||||
|
||||
UI API endpoints 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
|
||||
|
||||
@@ -162,3 +222,170 @@ Offline verification: `./mvnw -o -q verify` (Windows: `./mvnw.cmd -o -q verify`)
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE). Copyright 2026 Kingsrook, LLC; see [NOTICE](NOTICE).
|
||||
|
||||
## Tags
|
||||
|
||||
Phase d builds against **QQQ 4.1.0-RC.5**. Register host actions with
|
||||
`config.withTagActionTables(List.of("order"))`; `withDefaultTagMode("FREE")` is the
|
||||
default. Set `collaboration_feature_setting.feature = TAGS` and JSON
|
||||
`{"mode":"APPROVED_ONLY"}` to require approval. The service reads settings as the
|
||||
system, in TABLE → preferred APP → INSTANCE order, falling back to config. The
|
||||
first scope containing `mode` wins; `enabled` remains part of future general
|
||||
feature resolution. Invalid policy JSON or mode fails closed.
|
||||
|
||||
`TagsService` provides:
|
||||
|
||||
- `vocabulary(table, query, limit)`: global and table vocabulary, approved tags plus
|
||||
the caller's pending suggestions, excluding merged aliases; usage descending,
|
||||
then label. Default limit 20, maximum 100.
|
||||
- `create(tableOrNull, label, color)`: trimmed, lowercase, collapsed name of 1–60
|
||||
characters; the label retains its case. Duplicate names reuse the existing tag.
|
||||
Per-table creation requires `<table>.tag` or table EDIT and READ. FREE creates
|
||||
approved tags; APPROVED_ONLY creates suggestions unless the caller holds
|
||||
`collaboration.tags.manage`. Global creation always requires that manage grant.
|
||||
- `tagsFor(table, recordId)` and `apply(table, recordId, add, remove)`: parent READ
|
||||
and record locks are checked first. Writes also require the tag feature grant or
|
||||
EDIT. Numeric values are tag IDs; strings are labels. Unknown add labels are
|
||||
created in FREE; in APPROVED_ONLY they fail with
|
||||
“Pick an approved tag (or suggest one)”. Pending tags cannot be applied by anyone.
|
||||
Repeated changes are idempotent; actual changes write TAGGED/UNTAGGED activity,
|
||||
recount usage, and do not notify followers.
|
||||
|
||||
Tags is a main navigation table with FIELD auditing and native label search.
|
||||
Record Tags is a secured child list. Both use service-managed writes. Standard
|
||||
streamed **Approve tags**, **Merge tags**, and **Delete tags** actions require
|
||||
`collaboration.tags.manage`; Delete Review counts distinct affected parents.
|
||||
Merge deduplicates references and retains the source as an alias. Targets must be
|
||||
approved and in the source scope or global; a global source cannot merge into a
|
||||
single table's vocabulary.
|
||||
|
||||
Host **Add tags** and **Remove tags** actions use the table-type
|
||||
`collaboration_tag` PVS, filtered to approved, unmerged global or same-table tags.
|
||||
Each record is rechecked during Review and execution. Input `tags` is a list of
|
||||
integer IDs (comma-separated IDs are also accepted); `targetTagId` is a scalar.
|
||||
The picker step exposes `EDIT_FORM` with `multiSelectFields = ["tags"]`. RC.5 has
|
||||
no native multi-valued field type: rendering this component-value contract as a
|
||||
multi-select belongs to the separate Next UI phase. The qbit tests exercise the
|
||||
submitted arrays and real streamed Review/Result lifecycle.
|
||||
|
||||
`AttachmentCleanup.onParentsDeleted` now also removes tag assignments and
|
||||
recounts vocabulary usage. Hosts keep using this trusted callback until the core
|
||||
cleanup registration contract ships. The home shows session-visible vocabulary
|
||||
size, pending suggestions, and the five most-used approved tags.
|
||||
|
||||
`TagFilterTranslator.recordIds(table, operatorName, tags)` supports `IN` (any),
|
||||
`CONTAINS_ALL`, `NOT_IN` (none), `IS_BLANK`, and `IS_NOT_BLANK`. Every operator
|
||||
returns matching visible parent IDs as strings, including complements. Exactly
|
||||
10,000 results are allowed; larger results throw. Unknown tags match nothing;
|
||||
empty IN matches nothing and empty CONTAINS_ALL/NOT_IN match all visible parents.
|
||||
Numeric inputs are IDs, strings are normalized names; if a global and local tag
|
||||
share a name, the global tag wins (use IDs to disambiguate).
|
||||
|
||||
**Filter/quick-search integration lands after QRun-IO/qqq#1140 is released.** This
|
||||
phase does not register a virtual filter field, core expander, `QSubQuery`, tag
|
||||
column filler, or `#tag`/host-record quick-search provider. The standalone
|
||||
translator deliberately imports none of those unreleased types.
|
||||
|
||||
## Sharing and work services (phases h/i)
|
||||
|
||||
Services run in the caller's `QContext`. UI API endpoints and screens follow in a separate PR.
|
||||
|
||||
```java
|
||||
SharingService sharing = new SharingService();
|
||||
sharing.link("order", "123"); // Link(url, label)
|
||||
sharing.preview("order", "123", List.of("sam")); // canRead: true, false or null (unknown)
|
||||
sharing.share("order", "123", List.of("sam"), "Please review");
|
||||
sharing.markOpened(shareId); // recipient only
|
||||
|
||||
AccessRequestService access = new AccessRequestService();
|
||||
access.request("order", "123", "Please grant access", shareId); // optional shareId
|
||||
access.withdraw(requestId);
|
||||
|
||||
TasksService tasks = new TasksService();
|
||||
QRecord task = tasks.create("order", "123", "Review order", "Markdown notes", "sam", dueAt, null);
|
||||
tasks.complete(task.getValueInteger("id"));
|
||||
tasks.reopen(task.getValueInteger("id"));
|
||||
tasks.cancel(task.getValueInteger("id"));
|
||||
tasks.list("order", "123", false);
|
||||
tasks.mine("OPEN", 20, null); // Page(tasks, nextCursor)
|
||||
|
||||
AssignmentService assignments = new AssignmentService();
|
||||
assignments.assign("order", "123", "owner", "sam");
|
||||
assignments.get("order", "123");
|
||||
assignments.myRecords(null, 20); // owner OR assignee; per-table count and first 20
|
||||
new MyWorkService().summary();
|
||||
new MyWorkService().markMentionsRead(null); // all visible unread mentions; or supply IDs
|
||||
```
|
||||
|
||||
Set `frontendBaseUrl` for absolute record links; omission produces
|
||||
`/app/{encodedTable}/{encodedId}`. Sharing and notifications reuse `TargetResolver.recordHref`.
|
||||
This is trusted host configuration. Sharing requires parent READ and `<table>.share` or EDIT,
|
||||
1-25 recipients (deduplicated), and a message of at most 2,000 characters. Readers get the
|
||||
record label/message/link and SHARED following. False/unknown recipients get only sender name,
|
||||
table label and an access-request link. The sender's message is omitted because it may contain
|
||||
record data. The prefill route for the follow-up UI is:
|
||||
|
||||
`/app/collaboration_access_request/create?tableName={encodedTable}&recordId={encodedId}&shareId={id}`
|
||||
|
||||
The UI consumes these parameters and calls `request`; a supplied share ID must match its
|
||||
recipient and parent reference. Sharing never grants access. Parent-data notifications recheck
|
||||
recipient access at delivery, after commit. Access notices contain no parent data and can reach
|
||||
nonreaders.
|
||||
|
||||
Any signed-in user may request access, without a parent read or existence check. Existing and
|
||||
nonexistent targets return the same request shape and OPEN status; generated IDs/timestamps
|
||||
and submitted targets naturally differ. Duplicate OPEN requests return the existing row.
|
||||
A PostgreSQL partial unique index protects that natural key across instances. Ordinary table
|
||||
reads are requester/admin only; all direct writes are denied. Approver actions use an explicitly
|
||||
authorized extractor without widening ordinary table visibility.
|
||||
|
||||
Set TABLE-scope SHARING `settings` to `{"accessApproverPermission":"order.approve"}` (default:
|
||||
`collaboration.admin`). Configure `.withPermissionHolderDirectory(new QCodeReference(MyDirectory.class))`;
|
||||
`sharing.PermissionHolderDirectory.userIdsWithPermission(permission)` returns stable user IDs.
|
||||
Recipients are the union of admin holders, configured approver holders and the qbit record owner.
|
||||
Without a directory, only the owner is notified and a warning is logged once per JVM. Mark granted
|
||||
and Decline accept an optional note and tell the requester the outcome. Review explicitly says
|
||||
"Record the outcome; grant access in your identity system first." No permission is changed.
|
||||
Notifications omit free-form messages and notes.
|
||||
|
||||
Task create requires `task`. Changes additionally require creator, assignee or admin authority,
|
||||
while retaining parent visibility and task permission checks. Titles contain 1-200 characters;
|
||||
notes are markdown TEXT. An omitted create assignee defaults to self. Other assignees need a
|
||||
positive read check; false/unknown is refused with "Sam can't open this record." The full-field
|
||||
`update(id, title, notes, assignee, due, remind)` replaces editable fields: null clears an assignee
|
||||
or due time; null remind computes the default from due. The reminder defaults to due minus one
|
||||
day, or due itself when less than a day away. Assignments add ASSIGNED following and notify the
|
||||
new assignee unless self. Creation/completion writes activity and queues notify-on-change follower
|
||||
digests. Reopen clears completion fields but retains delivery history; changing due/reminder time
|
||||
or assignee resets the relevant delivery receipt.
|
||||
|
||||
The hidden `collaborationSendTaskReminders` process runs every 60 seconds with `schedulerName`.
|
||||
It requires an admin/system session and no open transaction. `TaskReminders(Clock)` and
|
||||
`TasksService(Clock)` allow deterministic time. Separate timestamps track successful reminder
|
||||
and overdue delivery; revoked/unknown access and the no-op notifier do not mark delivery.
|
||||
Run one scheduler per installation. A crash between delivery and receipt can cause a retry;
|
||||
stronger guarantees require transport idempotency, as with follow digests.
|
||||
|
||||
Configure `assignActionTables` for streamed Assign to and Take ownership actions. Assign needs
|
||||
`<table>.assign` or EDIT and verifies both roles can read (self passes after the parent check).
|
||||
`assign` replaces both roles, with null clearing a role; bulk actions preserve the other role.
|
||||
For host tables with their own owner/assignee columns, use core `RecordOwnership` metadata and
|
||||
normal host-table updates instead. Do not use the qbit assignment table or configure these bulk
|
||||
actions for such tables. Core phase-i virtual fields, CurrentUser and filter integration remain
|
||||
separate work; RC.5 does not provide that contract.
|
||||
|
||||
My work returns the exact visible open-task count and first ten, assigned-record counts per
|
||||
table, and delivered unread mentions. Personal lists/counts recheck each parent. This portable
|
||||
RC.5 implementation materializes matching candidates for null-last sorting/counting; large
|
||||
installations should replace it with core mirrored-lock queries when available. Task cursors
|
||||
encode `(dueAt, id)` and limits are 1-100. My records limits returned rows per table, counting all
|
||||
visible matches.
|
||||
|
||||
The home adds open access requests, open/overdue tasks and shares in the last seven days.
|
||||
Task/share aggregate counts retain the existing approximation for parent record locks until
|
||||
core mirrors them. `WorkCleanup` is registered for committed host deletes: it removes tasks,
|
||||
assignments and shares, retaining access requests. Attachment cleanup also invokes the
|
||||
idempotent hook. Schema generation includes the four tables, supporting indexes and
|
||||
`ALTER TABLE collaboration_mention ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ` for upgrades.
|
||||
Hosts apply generated SQL through their migration tooling; ESB/search publication uses the same
|
||||
host integrations as other collaboration tables.
|
||||
+93
-3
@@ -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 tags and phases h/i sharing, tasks and ownership services are implemented. Phases f/g and endpoint publication remain separate work. The table below is the full roadmap from the specification, not a list of shipped functionality.
|
||||
|
||||
| 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,96 @@ 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.
|
||||
|
||||
## Phase-d implementation choices
|
||||
|
||||
- **RC.5 boundary.** `tags/TagFilterTranslator` uses operator names, QQQ queries and
|
||||
string parent IDs. It returns matching rows for all operators, so NOT_IN and
|
||||
IS_BLANK enumerate the visible host-table complement. The 10,000 limit applies
|
||||
to final matching IDs, not assignment count. It pages parent reads under the
|
||||
caller's permissions and locks. Filter/quick-search integration lands after
|
||||
QRun-IO/qqq#1140 is released; no unreleased core types or virtual fields are used.
|
||||
- **Vocabulary versus assignments.** Tag names are unique per nullable scope;
|
||||
PostgreSQL DDL emits NULLS NOT DISTINCT for nullable unique-key components.
|
||||
Record Tags inherits `ParentTableRestriction` and the parent row customizer,
|
||||
including the existing approximation for aggregate counts and short pages.
|
||||
Vocabulary pickers need no collaboration.view grant: ordinary users see
|
||||
approved tags and their own suggestions in readable scopes, while manage sees
|
||||
all vocabulary for moderation. Direct writes are denied; services/actions
|
||||
preserve normalization, approval and counts.
|
||||
- **Policy lookup.** Settings are read in a temporary system context, retaining
|
||||
the transaction and restoring the caller. TABLE overrides preferred APP (QQQ's
|
||||
app-affinity path), then INSTANCE, then `defaultTagMode`. Missing mode inherits;
|
||||
malformed JSON/invalid mode throws. General enabled/permission-mode runtime
|
||||
overrides remain outside this phase. Name collision resolution prefers global
|
||||
vocabulary; IDs provide an unambiguous selection.
|
||||
- **Mutation boundaries.** Like existing services, writes participate in the
|
||||
caller's QQQ transaction and serialize within the installed config's monitor.
|
||||
Unique keys enforce assignment identity. Counts are recomputed from stored
|
||||
links, including merge deduplication, deletion and trusted parent cleanup.
|
||||
This is not a cross-pod lock: concurrent writers in separate instances need
|
||||
host transaction/serialization coordination for exact denormalized counts.
|
||||
Standard ETL runs with its process transaction. Activity is generated only for
|
||||
actual apply/remove changes and never calls follower notification services.
|
||||
- **Review and Result.** Actions derive operation and table from installed process
|
||||
metadata. Management requires collaboration.tags.manage at both stages. Bulk
|
||||
actions resolve approved PVS selections and recheck every parent and feature
|
||||
permission at both stages. Review is read-only, counts already-satisfied rows,
|
||||
and Delete reports distinct affected parents. Result reports successful and
|
||||
failed records separately. The picker exposes multiSelectFields on EDIT_FORM
|
||||
for the separate Next client; RC.5 itself has no native multi-value form field.
|
||||
- **Presentation.** Tags is in the admin app nav; Record Tags is a child widget.
|
||||
The home queries under the caller session and HTML-escapes labels. Native tag
|
||||
label search metadata is present; host-record tag search awaits #1140.
|
||||
|
||||
|
||||
## Phases h/i implementation choices
|
||||
|
||||
- Shares, tasks and assignments reuse parent checks, extension restrictions and FIELD audits.
|
||||
Access requests use requester/admin locks, no parent read, and metadata-only table labels.
|
||||
Trusted persistence removes only the request owner lock on a private metadata clone after
|
||||
explicit service authorization. Standard table actions retain that lock; audit keeps the caller.
|
||||
- Previews retain true/false/unknown. Unknown never enables record-data delivery or following.
|
||||
Safe access notices use after-commit delivery without a parent check and omit record labels,
|
||||
sender messages and approver notes. Record-data notifications recheck access at delivery.
|
||||
- OPEN request identity is `(tableName, recordId, requesterUserId)`. Local serialization and a
|
||||
PostgreSQL partial unique index protect it; insert collisions reread the existing row. No
|
||||
parent query distinguishes existing from missing records. Permission-holder resolution is an
|
||||
optional host SPI, with the qbit record owner as fallback. TABLE-scope SHARING settings supply
|
||||
the approver permission; native ownership integration awaits the released core phase-i API.
|
||||
- Streamed actions choose table/operation from installed metadata, validate in Review and again
|
||||
at load, and report Result counts. Configured approvers use an authorized request extractor
|
||||
without widening ordinary table reads. Grant records an identity-system outcome only.
|
||||
- Tasks default to the creator, compute reminder times using an injectable Clock, and keep
|
||||
independent reminder/overdue delivery receipts. Creation/completion also enter the existing
|
||||
follow digest. Run one scheduler per installation; delivery then receipt has the same crash
|
||||
retry/idempotency limitation as follow digests. Unknown access and no-op delivery leave no receipt.
|
||||
- Assignment is a qbit fallback. Hosts with owner/assignee columns use core RecordOwnership and
|
||||
ordinary updates, not collaboration_record_assignment or its configured actions. Bulk actions
|
||||
preserve the other role. Virtual fields and CurrentUser integration belong to core phase i.
|
||||
- My work/my-records counts recheck parents, removing invisible content and counts. RC.5 candidate
|
||||
rows are materialized for portable null-last sorting/counting. Home aggregates retain the
|
||||
existing documented parent-lock approximation until core mirrored locks are released.
|
||||
- The producer registers a host DELETE listener with after-commit cleanup. Access requests
|
||||
survive deletion. Schema SQL includes partial request/reminder indexes and a readAt migration
|
||||
for the existing mention table. Task title search metadata is present; ESB/Quick Search
|
||||
publication remains a host integration.
|
||||
|
||||
See the README for service signatures, defaults, configuration and the exact access-request
|
||||
prefill route. UI API endpoints and screens follow in their separate PR.
|
||||
@@ -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,15 @@ 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 String defaultTagMode = "FREE";
|
||||
private List<String> tagActionTables = List.of();
|
||||
private List<String> followActionTables = List.of();
|
||||
private List<String> followChangeTables = List.of();
|
||||
private String frontendBaseUrl;
|
||||
private QCodeReference permissionHolderDirectory;
|
||||
private List<String> assignActionTables = List.of();
|
||||
private String backendName;
|
||||
private String schedulerName;
|
||||
private String appName = "collaboration";
|
||||
@@ -244,6 +255,16 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
{
|
||||
assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors);
|
||||
}
|
||||
assertCondition(List.of("FREE", "APPROVED_ONLY").contains(defaultTagMode == null ? "" : defaultTagMode), "defaultTagMode must be FREE or APPROVED_ONLY", errors);
|
||||
assertCondition(notifier != null, "notifier is required", errors);
|
||||
for(List<String> tables : List.of(followActionTables, followChangeTables, tagActionTables, assignActionTables))
|
||||
{
|
||||
for(String table : tables)
|
||||
{
|
||||
assertCondition(table != null && !table.startsWith(TABLE_NAME_PREFIX) && instance.getTable(table) != null
|
||||
&& instance.getTable(table).getPrimaryKeyField() != null, "Follow configuration needs a host table with a primary key: " + table, errors);
|
||||
}
|
||||
}
|
||||
assertCondition(StringUtils.hasContent(attachmentsBucketName), "attachmentsBucketName is required", errors);
|
||||
assertCondition(defaultMaxBytes != null && defaultMaxBytes > 0, "defaultMaxBytes must be positive", errors);
|
||||
assertCondition(blockedExtensions != null && blockedExtensions.stream().allMatch(value -> value != null && !value.isBlank()), "blockedExtensions must contain nonblank extensions", errors);
|
||||
@@ -356,4 +377,157 @@ 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<String> getFollowActionTables()
|
||||
{
|
||||
return (followActionTables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure followActionTables.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withFollowActionTables(List<String> followActionTables)
|
||||
{
|
||||
this.followActionTables = followActionTables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get followChangeTables.
|
||||
*******************************************************************************/
|
||||
public List<String> getFollowChangeTables()
|
||||
{
|
||||
return (followChangeTables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure followChangeTables.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withFollowChangeTables(List<String> followChangeTables)
|
||||
{
|
||||
this.followChangeTables = followChangeTables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Default vocabulary policy when no persisted override supplies a mode.
|
||||
*******************************************************************************/
|
||||
public String getDefaultTagMode()
|
||||
{
|
||||
return (defaultTagMode);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the default tag policy.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withDefaultTagMode(String mode)
|
||||
{
|
||||
this.defaultTagMode = mode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host tables receiving Add tags and Remove tags actions.
|
||||
*******************************************************************************/
|
||||
public List<String> getTagActionTables()
|
||||
{
|
||||
return (tagActionTables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure host tag actions.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withTagActionTables(List<String> tables)
|
||||
{
|
||||
this.tagActionTables = tables;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host configuration for frontendBaseUrl.
|
||||
*******************************************************************************/
|
||||
public String getFrontendBaseUrl()
|
||||
{
|
||||
return (frontendBaseUrl);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure frontendBaseUrl.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withFrontendBaseUrl(String value)
|
||||
{
|
||||
this.frontendBaseUrl = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host configuration for permissionHolderDirectory.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getPermissionHolderDirectory()
|
||||
{
|
||||
return (permissionHolderDirectory);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure permissionHolderDirectory.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withPermissionHolderDirectory(QCodeReference value)
|
||||
{
|
||||
this.permissionHolderDirectory = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host configuration for assignActionTables.
|
||||
*******************************************************************************/
|
||||
public List<String> getAssignActionTables()
|
||||
{
|
||||
return (assignActionTables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure assignActionTables.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withAssignActionTables(List<String> value)
|
||||
{
|
||||
this.assignActionTables = value;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,27 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
CollaborationSecurity.buildSecurityKeyTypes().forEach(output::add);
|
||||
output.add(TrimRecentsProcess.metadata(qBitConfig));
|
||||
output.add(com.kingsrook.qbits.collaboration.tasks.SendTaskRemindersProcess.metadata(qBitConfig));
|
||||
WorkProcesses.management().forEach(output::add);
|
||||
for(String table : qBitConfig.getAssignActionTables())
|
||||
{
|
||||
WorkProcesses.assignments(table).forEach(output::add);
|
||||
}
|
||||
instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(WorkCleanup.class));
|
||||
output.add(com.kingsrook.qbits.collaboration.follow.SendFollowDigestsProcess.metadata(qBitConfig));
|
||||
for(String table : qBitConfig.getFollowActionTables())
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.follow.FollowProcesses.processesFor(table).forEach(output::add);
|
||||
}
|
||||
if(!qBitConfig.getFollowChangeTables().isEmpty())
|
||||
{
|
||||
instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.collaboration.follow.FollowChangeBatcher.class));
|
||||
}
|
||||
com.kingsrook.qbits.collaboration.tags.TagProcesses.management().forEach(output::add);
|
||||
for(String table : qBitConfig.getTagActionTables())
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.tags.TagProcesses.processesFor(table).forEach(output::add);
|
||||
}
|
||||
output.add(DeleteAttachmentsProcess.metadata());
|
||||
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
|
||||
{
|
||||
@@ -144,6 +165,7 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
output.add(CollaborationHome.app(qBitConfig));
|
||||
}
|
||||
output.addSelfToInstance(instance);
|
||||
com.kingsrook.qbits.collaboration.model.ConversationChildren.install(instance);
|
||||
if(Boolean.TRUE.equals(qBitConfig.getAdminApp()) && qBitConfig.getParentAppName() != null)
|
||||
{
|
||||
QAppMetaData parent = instance.getApp(qBitConfig.getParentAppName());
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal persistence for trusted services. Callers must authorize the parent
|
||||
** before reading or writing. Never expose these methods as API operations.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationRecords
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static persistence boundary.
|
||||
*******************************************************************************/
|
||||
private CollaborationRecords()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the installed qbit configuration.
|
||||
*******************************************************************************/
|
||||
public static CollaborationQBitConfig config()
|
||||
{
|
||||
return ((CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
|
||||
.filter(bit -> bit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Restrict a service query to one canonical parent.
|
||||
*******************************************************************************/
|
||||
public static QQueryFilter parent(String table, String id)
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
|
||||
.withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Raw reads retain transaction visibility but omit presentation customizers.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(serviceMetadata(table));
|
||||
input.withPermissionBypass("Trusted collaboration service performs parent/ownership authorization.");
|
||||
return (new QueryAction().execute(input).getRecords().stream().map(QRecord::new).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist generated keys and propagate record validation errors.
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String table, QRecord row) throws QException
|
||||
{
|
||||
InsertInput input = new InsertInput(table).withRecords(List.of(row)).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(serviceMetadata(table));
|
||||
input.withPermissionBypass("Trusted collaboration service authorized this write.");
|
||||
List<QRecord> result = new InsertAction().execute(input).getRecords();
|
||||
check(result);
|
||||
return (result.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only supplied fields are changed; preserve auditing.
|
||||
*******************************************************************************/
|
||||
public static void update(String table, QRecord row) throws QException
|
||||
{
|
||||
UpdateInput input = new UpdateInput(table).withRecords(List.of(row)).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(serviceMetadata(table));
|
||||
input.withPermissionBypass("Trusted collaboration service authorized this write.");
|
||||
check(new UpdateAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a service-selected row.
|
||||
*******************************************************************************/
|
||||
public static void delete(String table, Serializable id) throws QException
|
||||
{
|
||||
DeleteInput input = new DeleteInput(table).withPrimaryKeys(List.of(id)).withTransaction(QContext.getQBackendTransaction());
|
||||
input.setTableMetaData(serviceMetadata(table));
|
||||
input.withPermissionBypass("Trusted collaboration service authorized this deletion.");
|
||||
check(new DeleteAction().execute(input).getRecordsWithErrors());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Access-request services enforce requester/approver authority explicitly.
|
||||
** Bypass only that owner lock on a private metadata clone, retaining the caller
|
||||
** for audit attribution. Standard actions always retain the installed lock.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData serviceMetadata(String table)
|
||||
{
|
||||
QTableMetaData metadata = QContext.getQInstance().getTable(table).clone().withCustomizers(null);
|
||||
if(table.equals(com.kingsrook.qbits.collaboration.model.CollaborationTables.ACCESS_REQUEST))
|
||||
{
|
||||
metadata.setRecordSecurityLocks(null);
|
||||
}
|
||||
return (metadata);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core actions can return validation errors instead of throwing.
|
||||
*******************************************************************************/
|
||||
private static void check(List<QRecord> rows) throws QException
|
||||
{
|
||||
if(rows != null)
|
||||
{
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
if(row.getErrors() != null && !row.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not save collaboration record: " + row.getErrors()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,10 +66,29 @@ public final class CollaborationSchema
|
||||
index++;
|
||||
List<String> names = key.getFieldNames().stream().map(name -> table.getField(name).getBackendName()).toList();
|
||||
statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_" + table.getName() + "_" + index + " ON " + table.getName() + " (" + String.join(", ", names) + ")"
|
||||
+ (key.getFieldNames().contains("recordId") ? " NULLS NOT DISTINCT" : ""));
|
||||
+ (key.getFieldNames().stream().anyMatch(name -> !Boolean.TRUE.equals(table.getField(name).getIsRequired())) ? " NULLS NOT DISTINCT" : ""));
|
||||
}
|
||||
}
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_record_tag_tag ON collaboration_record_tag (tag_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_parent ON collaboration_attachment (table_name, record_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_parent ON collaboration_comment (table_name, record_id, last_activity_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_created ON collaboration_comment (table_name, record_id, create_date)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_thread ON collaboration_comment (thread_id, create_date, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_revision_comment ON collaboration_comment_revision (comment_id, edited_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_activity_parent ON collaboration_activity (table_name, record_id, at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_digest_due ON collaboration_follow_digest (due_at)");
|
||||
statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_access_request_open ON collaboration_access_request (table_name, record_id, requester_user_id) WHERE status = 'OPEN'");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_share_parent ON collaboration_share (table_name, record_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_share_sent ON collaboration_share (sent_at)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_access_request_requester ON collaboration_access_request (requester_user_id, status)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_task_parent ON collaboration_task (table_name, record_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_task_mine ON collaboration_task (assignee_user_id, status, due_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_task_reminder ON collaboration_task (remind_at) WHERE status = 'OPEN' AND reminded_at IS NULL");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_task_overdue ON collaboration_task (due_at) WHERE status = 'OPEN' AND overdue_notified_at IS NULL");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_assignment_owner ON collaboration_record_assignment (owner_user_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_assignment_assignee ON collaboration_record_assignment (assignee_user_id)");
|
||||
statements.add("ALTER TABLE collaboration_mention ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_mention_unread ON collaboration_mention (mentioned_user_id) WHERE notified_at IS NOT NULL AND read_at IS NULL");
|
||||
return (String.join(";\n", statements) + ";\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,15 @@ public final class TargetResolver
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared canonical record href, with optional configured frontend origin.
|
||||
*******************************************************************************/
|
||||
public static String recordHref(String table, String id)
|
||||
{
|
||||
String base = CollaborationRecords.config().getFrontendBaseUrl();
|
||||
return ((base == null ? "" : base.replaceAll("/+$", "")) + path(table) + encode(id));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Flat Next router path; trailing slash also supports dotted process names.
|
||||
*******************************************************************************/
|
||||
@@ -309,7 +318,7 @@ public final class TargetResolver
|
||||
/*******************************************************************************
|
||||
** Encode a single route segment, including slash, hash and spaces in ids.
|
||||
*******************************************************************************/
|
||||
private static String encode(String value)
|
||||
public static String encode(String value)
|
||||
{
|
||||
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeEvent;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted after-delete hook. Access requests deliberately survive parent deletion.
|
||||
*******************************************************************************/
|
||||
public class WorkCleanup implements RecordChangeListenerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Host deletions only; extension deletes must not recursively invoke cleanup.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean appliesTo(String table, RecordChangeType type)
|
||||
{
|
||||
return (type == RecordChangeType.DELETE && !table.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Defer cleanup until the parent deletion commits.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onRecordsChanged(RecordChangeEvent event) throws QException
|
||||
{
|
||||
if(!appliesTo(event.getTableName(), event.getType()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String table = event.getTableName();
|
||||
String key = QContext.getQInstance().getTable(table).getPrimaryKeyField();
|
||||
List<Serializable> ids = event.getRecords().stream().map(row -> row.getValue(key)).toList();
|
||||
if(event.getTransaction() == null)
|
||||
{
|
||||
onParentsDeleted(table, ids);
|
||||
}
|
||||
else
|
||||
{
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
event.getTransaction().addAfterCommitCallback(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> onParentsDeleted(table, ids));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not clean up collaboration work", e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded batches and idempotent deletion also support explicit host callbacks.
|
||||
*******************************************************************************/
|
||||
public void onParentsDeleted(String table, List<? extends Serializable> ids) throws QException
|
||||
{
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(String child : List.of(CollaborationTables.TASK, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.SHARE))
|
||||
{
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> rows = CollaborationRecords.query(child, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
|
||||
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()).withLimit(200));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
CollaborationRecords.delete(child, row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qbits.collaboration.sharing.AccessRequestService;
|
||||
import com.kingsrook.qbits.collaboration.tasks.TasksService;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
/*******************************************************************************
|
||||
** Streamed Review/Result actions. Services reauthorize all writes at load time.
|
||||
*******************************************************************************/
|
||||
public final class WorkProcesses
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata factory.
|
||||
*******************************************************************************/
|
||||
private WorkProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record actions stay on their source tables, never in app navigation.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> management()
|
||||
{
|
||||
return (List.of(process(CollaborationTables.ACCESS_REQUEST, "Grant", "Mark granted"), process(CollaborationTables.ACCESS_REQUEST, "Decline", "Decline"),
|
||||
process(CollaborationTables.TASK, "Done", "Mark done"), process(CollaborationTables.TASK, "Reopen", "Reopen")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Hosts opt in only tables without their own ownership columns.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> assignments(String table)
|
||||
{
|
||||
return (List.of(process(table, "Assign", "Assign to"), process(table, "Own", "Take ownership")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional notes and assignee input precede the standard Review screen.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData process(String table, String operation, String label)
|
||||
{
|
||||
boolean access = table.equals(CollaborationTables.ACCESS_REQUEST);
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, access
|
||||
? "Record the outcome; grant access in your identity system first." : label + " for the selected records.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(access ? AccessExtract.class : ExtractViaQueryStep.class, Review.class, Apply.class, values)
|
||||
.withName("collaboration" + operation + "_" + table).withLabel(label).withTableName(table).withIcon(new QIcon("assignment"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(ActionSecurity.class)));
|
||||
if(access || operation.equals("Assign"))
|
||||
{
|
||||
QFieldMetaData field = new QFieldMetaData(access ? "note" : "assigneeUserId", access ? QFieldType.TEXT : QFieldType.STRING)
|
||||
.withLabel(access ? "Note" : "Assignee user ID").withIsRequired(!access).withMaxLength(access ? 2000 : 255);
|
||||
process.withStep(0, new QFrontendStepMetaData().withName("details").withLabel(label).withFormField(field)
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Operation and table come from installed process metadata.
|
||||
*******************************************************************************/
|
||||
private static String operation(RunBackendStepInput input)
|
||||
{
|
||||
return (input.getProcess().getName().substring("collaboration".length()).split("_")[0]);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Share the same dry-run authorization between Review and load.
|
||||
*******************************************************************************/
|
||||
private static void validate(RunBackendStepInput input, QRecord row) throws QException
|
||||
{
|
||||
String table = input.getProcess().getTableName();
|
||||
if(table.equals(CollaborationTables.ACCESS_REQUEST))
|
||||
{
|
||||
new AccessRequestService().requireApprover(row.getValueInteger("id"));
|
||||
WorkSupport.text(input.getValueString("note"), 2000, false);
|
||||
}
|
||||
else if(table.equals(CollaborationTables.TASK))
|
||||
{
|
||||
new TasksService().requireManager(row.getValueInteger("id"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Serializable id = row.getValue(QContext.getQInstance().getTable(table).getPrimaryKeyField());
|
||||
ParentRecordAccess.requireFeature(table, id, "assign");
|
||||
if(operation(input).equals("Assign"))
|
||||
{
|
||||
WorkSupport.text(input.getValueString("assigneeUserId"), 255, true);
|
||||
WorkSupport.requireReader(input.getValueString("assigneeUserId"), table, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the role that the selected action does not modify.
|
||||
*******************************************************************************/
|
||||
private static void apply(RunBackendStepInput input, QRecord row) throws QException
|
||||
{
|
||||
validate(input, row);
|
||||
String table = input.getProcess().getTableName();
|
||||
String operation = operation(input);
|
||||
if(table.equals(CollaborationTables.ACCESS_REQUEST))
|
||||
{
|
||||
new AccessRequestService().handle(row.getValueInteger("id"), operation.equals("Grant") ? "GRANTED" : "DECLINED", input.getValueString("note"));
|
||||
}
|
||||
else if(table.equals(CollaborationTables.TASK))
|
||||
{
|
||||
if(operation.equals("Done"))
|
||||
{
|
||||
new TasksService().complete(row.getValueInteger("id"));
|
||||
}
|
||||
else
|
||||
{
|
||||
new TasksService().reopen(row.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Serializable id = row.getValue(QContext.getQInstance().getTable(table).getPrimaryKeyField());
|
||||
AssignmentService service = new AssignmentService();
|
||||
QRecord old = service.get(table, id).orElse(new QRecord());
|
||||
service.assign(table, id, operation.equals("Own") ? PersonalRecords.currentUserId() : old.getValueString("ownerUserId"),
|
||||
operation.equals("Assign") ? input.getValueString("assigneeUserId") : old.getValueString("assigneeUserId"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Consistent Review and Result counts.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String future, String past)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(future);
|
||||
line.setPluralFutureMessage(future);
|
||||
line.setSingularPastMessage(past);
|
||||
line.setPluralPastMessage(past);
|
||||
return (line);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Restrict action availability while keeping row authorization in the services.
|
||||
*******************************************************************************/
|
||||
public static class ActionSecurity implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Access actions require admin or a configured approver permission.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
PersonalRecords.currentUserId();
|
||||
String table = ((QProcessMetaData) metadata).getTableName();
|
||||
if(table.equals(CollaborationTables.ACCESS_REQUEST))
|
||||
{
|
||||
if(QContext.getQSession().hasPermission("collaboration.admin"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
for(String host : QContext.getQInstance().getTables().keySet())
|
||||
{
|
||||
if(!host.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX)
|
||||
&& QContext.getQSession().hasPermission(new AccessRequestService().approverPermission(host)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Could not resolve access approver permission", e));
|
||||
}
|
||||
throw (new QPermissionDeniedException("This needs the access approver permission."));
|
||||
}
|
||||
if(!table.equals(CollaborationTables.TASK))
|
||||
{
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireTableFeature(table, "assign");
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the table assign permission or EDIT.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Approvers can process requests without broadening requester/admin table reads.
|
||||
*******************************************************************************/
|
||||
public static class AccessExtract extends ExtractViaQueryStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Only authorized rows enter the process pipe or appear in its preview.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
int count = 0;
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.ACCESS_REQUEST, getQueryFilter(input)))
|
||||
{
|
||||
new AccessRequestService().requireApprover(row.getValueInteger("id"));
|
||||
getRecordPipe().addRecord(row);
|
||||
count++;
|
||||
if(getLimit() != null && count >= getLimit())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Avoid exposing an unfiltered count outside the table owner lock.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer doCount(RunBackendStepInput input)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review validates recipient access and authority without writing.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine selected = line(Status.OK, "records will be processed", "records processed");
|
||||
private final ProcessSummaryLine skipped = line(Status.WARNING, "records unavailable (skipped)", "records skipped");
|
||||
|
||||
/*******************************************************************************
|
||||
** Only validated rows advance to load.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(input.getProcess().getTableName().equals(CollaborationTables.ACCESS_REQUEST))
|
||||
{
|
||||
selected.setSingularFutureMessage("Record the outcome; grant access in your identity system first.");
|
||||
selected.setPluralFutureMessage("Record the outcome; grant access in your identity system first.");
|
||||
}
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
try
|
||||
{
|
||||
validate(input, row);
|
||||
output.getRecords().add(row);
|
||||
selected.incrementCountAndAddPrimaryKey(row.getValue(QContext.getQInstance().getTable(input.getProcess().getTableName()).getPrimaryKeyField()));
|
||||
}
|
||||
catch(QException expected)
|
||||
{
|
||||
skipped.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit identity-system prerequisite accompanies access-request reviews.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
selected.addSelfToListIfAnyCount(lines);
|
||||
skipped.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Result reports actual writes and any permission changes since Review.
|
||||
*******************************************************************************/
|
||||
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine done = line(Status.OK, "records will be processed", "records processed");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "records cannot be processed", "records failed (retry required)");
|
||||
|
||||
/*******************************************************************************
|
||||
** Each write goes through its service authorization again.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
try
|
||||
{
|
||||
apply(input, row);
|
||||
done.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
catch(QException expected)
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain Review skips alongside successful and failed loads.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
done.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
if(getTransformStep() instanceof Review review)
|
||||
{
|
||||
review.skipped.addSelfToListIfAnyCount(lines);
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.people.PeopleService;
|
||||
import com.kingsrook.qbits.collaboration.security.OtherUserAccess;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared validation for service-managed work records.
|
||||
*******************************************************************************/
|
||||
public final class WorkSupport
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static helpers only.
|
||||
*******************************************************************************/
|
||||
private WorkSupport()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up an extension row and enforce its parent before returning data.
|
||||
*******************************************************************************/
|
||||
public static QRecord visible(String table, Integer id) throws QException
|
||||
{
|
||||
QRecord row = row(table, id);
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("parentDeleted")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted lookup: callers must authorize ownership or parent access themselves.
|
||||
*******************************************************************************/
|
||||
public static QRecord row(String table, Integer id) throws QException
|
||||
{
|
||||
List<QRecord> rows = CollaborationRecords.query(table, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Self passes after the caller's parent check; unknown others fail closed.
|
||||
*******************************************************************************/
|
||||
public static void requireReader(String user, String table, Serializable id) throws QException
|
||||
{
|
||||
if(user != null && (user.isBlank() || (!user.equals(PersonalRecords.currentUserId())
|
||||
&& !new OtherUserAccess().canRead(user, table, id).orElse(false))))
|
||||
{
|
||||
throw (new QException(new PeopleService().displayName(user) + " can't open this record."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded text validation before any write or notification.
|
||||
*******************************************************************************/
|
||||
public static void text(String value, int max, boolean required) throws QException
|
||||
{
|
||||
if((required && (value == null || value.isBlank())) || (value != null && value.length() > max))
|
||||
{
|
||||
throw (new QException("Text must contain " + (required ? "1" : "0") + " to " + max + " characters."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.activity;
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal activity writer; the calling service has already checked the parent.
|
||||
*******************************************************************************/
|
||||
public final class ActivityWriter
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static service boundary.
|
||||
*******************************************************************************/
|
||||
private ActivityWriter()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Store references, never copies of comment bodies or attachment contents.
|
||||
*******************************************************************************/
|
||||
public static void write(String table, String recordId, String type, String summary, String refTable, String refId) throws QException
|
||||
{
|
||||
CollaborationRecords.insert(CollaborationTables.ACTIVITY, new QRecord().withValue("tableName", table).withValue("recordId", recordId)
|
||||
.withValue("at", Instant.now()).withValue("actorUserId", CollaborationSecurity.userId(QContext.getQSession()))
|
||||
.withValue("type", type).withValue("summary", summary).withValue("refTableName", refTable).withValue("refId", refId).withValue("parentDeleted", false));
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateRe
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
@@ -82,7 +83,7 @@ public final class CollaborationHome
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.withWidgets(List.of(HOME_WIDGET))
|
||||
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
|
||||
.withTables(List.of(CollaborationTables.ATTACHMENT, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
|
||||
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
|
||||
app.setParentAppName(config.getParentAppName());
|
||||
return (app);
|
||||
}
|
||||
@@ -140,11 +141,53 @@ public final class CollaborationHome
|
||||
personalSummary(html, CollaborationTables.FAVORITE, "Favorites");
|
||||
personalSummary(html, CollaborationTables.RECENT, "Recently viewed");
|
||||
attachmentSummary(html);
|
||||
tagSummary(html);
|
||||
html.append("<section><h3>Open access requests</h3><p>").append(count(CollaborationTables.ACCESS_REQUEST,
|
||||
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
|
||||
html.append("<section><h3>Open tasks</h3><p>").append(count(CollaborationTables.TASK,
|
||||
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
|
||||
html.append("<section><h3>Overdue tasks</h3><p>").append(count(CollaborationTables.TASK,
|
||||
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN").withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now))).append("</p></section>");
|
||||
html.append("<section><h3>Shares this week</h3><p>").append(count(CollaborationTables.SHARE,
|
||||
new QQueryFilter().withCriteria("sentAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
|
||||
.withCriteria("sentAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))).append("</p></section>");
|
||||
html.append("<section><h3>Comments this week</h3><p>").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("</p></section>");
|
||||
html.append("<section><h3>Open threads</h3><p>").append(count(CollaborationTables.COMMENT, new QQueryFilter()
|
||||
.withCriteria("threadId", QCriteriaOperator.IS_BLANK).withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK)
|
||||
.withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("</p></section>");
|
||||
if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
|
||||
{
|
||||
html.append("<section><h3>Followers</h3><p>").append(count(CollaborationTables.FOLLOW, new QQueryFilter())).append("</p></section>");
|
||||
}
|
||||
html.append("<p>Conversation totals are approximate where parent record locks require post-filtering.</p>");
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Caller-filtered vocabulary totals and five most used active tags.
|
||||
*******************************************************************************/
|
||||
private static void tagSummary(StringBuilder html) throws QException
|
||||
{
|
||||
QQueryFilter active = new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK);
|
||||
html.append("<section><h3>Tags</h3><p>Vocabulary size: ").append(count(CollaborationTables.TAG, active))
|
||||
.append("</p><p>Pending suggestions: ").append(count(CollaborationTables.TAG, new QQueryFilter()
|
||||
.withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK).withCriteria("approved", QCriteriaOperator.EQUALS, false)))
|
||||
.append("</p><ul>");
|
||||
com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput input = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(CollaborationTables.TAG)
|
||||
.withFilter(active.withCriteria("approved", QCriteriaOperator.EQUALS, true).withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(5));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
for(com.kingsrook.qqq.backend.core.model.data.QRecord tag : new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(input).getRecords())
|
||||
{
|
||||
html.append("<li>").append(tag.getValueString("label").replace("&", "&").replace("<", "<").replace(">", ">"))
|
||||
.append(": ").append(tag.getValueInteger("usageCount")).append("</li>");
|
||||
}
|
||||
html.append("</ul></section>");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Real table aggregates, restricted to readable parent tables. Parent locks
|
||||
** are post-filtered on rows, so these aggregate totals are approximate.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.assignment;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.WorkSupport;
|
||||
import com.kingsrook.qbits.collaboration.activity.ActivityWriter;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
|
||||
import com.kingsrook.qbits.collaboration.notify.Notifications;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Ownership storage for hosts without native RecordOwnership columns.
|
||||
*******************************************************************************/
|
||||
public class AssignmentService
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Full replacement of both roles; null clears a role.
|
||||
*******************************************************************************/
|
||||
public QRecord assign(String table, Serializable recordId, String ownerUserId, String assigneeUserId) throws QException
|
||||
{
|
||||
ParentRecordAccess.requireFeature(table, recordId, "assign");
|
||||
String id = FollowService.canonicalId(table, recordId);
|
||||
WorkSupport.requireReader(ownerUserId, table, id);
|
||||
WorkSupport.requireReader(assigneeUserId, table, id);
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
Optional<QRecord> old = get(table, id);
|
||||
QRecord change = new QRecord().withValue("ownerUserId", ownerUserId).withValue("assigneeUserId", assigneeUserId);
|
||||
QRecord row;
|
||||
if(old.isEmpty())
|
||||
{
|
||||
row = CollaborationRecords.insert(CollaborationTables.RECORD_ASSIGNMENT, change.withValue("tableName", table).withValue("recordId", id).withValue("parentDeleted", false));
|
||||
}
|
||||
else
|
||||
{
|
||||
CollaborationRecords.update(CollaborationTables.RECORD_ASSIGNMENT, change.withValue("id", old.get().getValue("id")));
|
||||
row = get(table, id).orElseThrow();
|
||||
}
|
||||
ActivityWriter.write(table, id, "ASSIGNED", "Record assignment changed", CollaborationTables.RECORD_ASSIGNMENT, row.getValueString("id"));
|
||||
if(assigneeUserId != null && (old.isEmpty() || !Objects.equals(old.get().getValueString("assigneeUserId"), assigneeUserId)))
|
||||
{
|
||||
new FollowService().ensureFollower(assigneeUserId, table, id, "ASSIGNED");
|
||||
if(!assigneeUserId.equals(PersonalRecords.currentUserId()))
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(assigneeUserId, "A record was assigned to you", null,
|
||||
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)));
|
||||
}
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parent readers can see assignment independently of the admin-only table nav.
|
||||
*******************************************************************************/
|
||||
public Optional<QRecord> get(String table, Serializable recordId) throws QException
|
||||
{
|
||||
return (CollaborationRecords.query(CollaborationTables.RECORD_ASSIGNMENT, CollaborationRecords.parent(table, FollowService.canonicalId(table, recordId))).stream().findFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts cover all visible matches; limit bounds the rows returned per table.
|
||||
*******************************************************************************/
|
||||
public Map<String, Group> myRecords(String table, Integer limit) throws QException
|
||||
{
|
||||
int size = limit == null ? 20 : limit;
|
||||
if(size < 1 || size > 100)
|
||||
{
|
||||
throw (new QException("Limit must be between 1 and 100."));
|
||||
}
|
||||
Map<String, List<QRecord>> groups = new LinkedHashMap<>();
|
||||
for(QRecord row : mineRows(table, false))
|
||||
{
|
||||
groups.computeIfAbsent(row.getValueString("tableName"), key -> new ArrayList<>()).add(row);
|
||||
}
|
||||
Map<String, Group> result = new LinkedHashMap<>();
|
||||
groups.forEach((name, rows) -> result.put(name, new Group((long) rows.size(), rows.stream().limit(size).toList())));
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh parent checks protect both personal rows and counts after access changes.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> mineRows(String table, Boolean assignedOnly) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
QQueryFilter roles = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("assigneeUserId", QCriteriaOperator.EQUALS, user);
|
||||
if(!Boolean.TRUE.equals(assignedOnly))
|
||||
{
|
||||
roles.withCriteria("ownerUserId", QCriteriaOperator.EQUALS, user);
|
||||
}
|
||||
QQueryFilter filter = new QQueryFilter().withSubFilter(roles).withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false)
|
||||
.withOrderBy(new QFilterOrderBy("tableName")).withOrderBy(new QFilterOrderBy("id"));
|
||||
if(table != null)
|
||||
{
|
||||
filter.withCriteria("tableName", QCriteriaOperator.EQUALS, table);
|
||||
}
|
||||
List<QRecord> visible = new ArrayList<>();
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.RECORD_ASSIGNMENT, filter))
|
||||
{
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
|
||||
visible.add(row);
|
||||
}
|
||||
catch(QNotFoundException expected)
|
||||
{
|
||||
// Revoked access must not contribute even to the count.
|
||||
}
|
||||
}
|
||||
return (visible);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Total and first page for one host table.
|
||||
*******************************************************************************/
|
||||
public record Group(Long count, List<QRecord> records)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,8 @@ public class AttachmentCleanup
|
||||
{
|
||||
return;
|
||||
}
|
||||
new com.kingsrook.qbits.collaboration.WorkCleanup().onParentsDeleted(table, ids);
|
||||
new com.kingsrook.qbits.collaboration.tags.TagsService().onParentsDeleted(table, ids);
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
|
||||
|
||||
+1
-15
@@ -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<String> readable = QContext.getQInstance().getTables().keySet().stream().filter(ParentRecordAccess::canReadTable).toList();
|
||||
QQueryFilter original = input.getFilter();
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("tableName", QCriteriaOperator.IN, readable)
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false);
|
||||
if(original != null)
|
||||
{
|
||||
filter.withSubFilter(original).withLimit(original.getLimit()).withSkip(original.getSkip());
|
||||
filter.setOrderBys(original.getOrderBys());
|
||||
}
|
||||
input.setFilter(filter);
|
||||
com.kingsrook.qbits.collaboration.security.ParentTableRestriction.restrict(input);
|
||||
}
|
||||
}
|
||||
@@ -45,11 +45,14 @@ public class AttachmentStorage
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Enforce the bucket's cap as well as collaboration's configured cap.
|
||||
** The bucket's own upload cap, when object storage exposes it through its api
|
||||
** package. It does not yet (requested from qbit-object-storage), so this
|
||||
** returns Long.MAX_VALUE: collaboration enforces its configured per-table cap,
|
||||
** and object storage still enforces the bucket's cap when the object is put.
|
||||
*******************************************************************************/
|
||||
public long maxBytes(String name)
|
||||
{
|
||||
return (bucket(name).config().getMaxUploadBytes());
|
||||
return (Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+14
-1
@@ -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<QRecord> records = new InsertAction().execute(input).getRecords();
|
||||
AttachmentRecords.checkErrors(records);
|
||||
rowSaved = true;
|
||||
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(tableName, id, "FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, records.getFirst().getValueString("id"));
|
||||
return (entry(records.getFirst()));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
storage.delete(bucket, key);
|
||||
if(!rowSaved)
|
||||
{
|
||||
storage.delete(bucket, key);
|
||||
}
|
||||
}
|
||||
catch(RuntimeException cleanupFailure)
|
||||
{
|
||||
@@ -180,6 +187,7 @@ public class AttachmentsService
|
||||
*******************************************************************************/
|
||||
public List<AttachmentEntry> list(String tableName, Serializable recordId) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
String id = canonicalId(tableName, recordId);
|
||||
List<AttachmentEntry> entries = new ArrayList<>();
|
||||
for(QRecord row : AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName)
|
||||
@@ -197,6 +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<? extends Serializable> attachmentIds) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
for(Serializable id : attachmentIds.stream().distinct().toList())
|
||||
{
|
||||
@@ -226,6 +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))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.comments;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/*******************************************************************************
|
||||
** Thread item; deleted bodies are never returned.
|
||||
*******************************************************************************/
|
||||
public record CommentEntry(Integer id, Integer threadId, Integer parentCommentId, String body, String fieldName, String authorUserId, String authorDisplayName, Instant createdAt, Instant editedAt, Boolean deleted, Boolean resolved, Boolean canEdit, Boolean canDelete, List<MentionEntry> mentions, List<CommentEntry> replies)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.comments;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/*******************************************************************************
|
||||
** A page counts root threads; replies stay grouped under each root.
|
||||
*******************************************************************************/
|
||||
public record CommentPage(List<CommentEntry> threads, String nextCursor)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.comments;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/*******************************************************************************
|
||||
** Prior body and the identity/time of the edit that replaced it.
|
||||
*******************************************************************************/
|
||||
public record CommentRevision(Integer id, String body, Instant editedAt, String editedByUserId)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,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<String> mentions = parse(body);
|
||||
Integer rootId = null;
|
||||
if(parentCommentId != null)
|
||||
{
|
||||
QRecord parent = requireComment(parentCommentId);
|
||||
if(!table.equals(parent.getValueString("tableName")) || !id.equals(parent.getValueString("recordId")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
rootId = parent.getValueInteger("threadId") == null ? parent.getValueInteger("id") : parent.getValueInteger("threadId");
|
||||
}
|
||||
if(fieldName != null && !QContext.getQInstance().getTable(table).getFields().containsKey(fieldName))
|
||||
{
|
||||
throw (new 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<String> notified = processMentions(row, mentions);
|
||||
new FollowService().ensureFollower(PersonalRecords.currentUserId(), table, id, "COMMENTED");
|
||||
ActivityWriter.write(table, id, "COMMENTED", "Comment added", CollaborationTables.COMMENT, row.getValueString("id"));
|
||||
notifyFollowers(row, notified);
|
||||
return (entry(row, List.of()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keyset-page root threads by latest activity; fetch replies oldest first.
|
||||
*******************************************************************************/
|
||||
public CommentPage list(String table, Serializable recordId, String cursor, Integer limit) throws QException
|
||||
{
|
||||
new PeopleService().touch();
|
||||
String id = FollowService.canonicalId(table, recordId);
|
||||
requireLimit(limit);
|
||||
RecordCursor before = cursor == null ? null : RecordCursor.decode(cursor);
|
||||
if(before != null && !before.kind().equals("COMMENT"))
|
||||
{
|
||||
throw (new 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<QRecord> roots = CollaborationRecords.query(CollaborationTables.COMMENT, filter.withLimit(limit + 1));
|
||||
List<CommentEntry> result = new ArrayList<>();
|
||||
for(QRecord row : roots.stream().limit(limit).toList())
|
||||
{
|
||||
List<CommentEntry> replies = new ArrayList<>();
|
||||
for(QRecord reply : CollaborationRecords.query(CollaborationTables.COMMENT, CollaborationRecords.parent(table, id)
|
||||
.withCriteria("threadId", QCriteriaOperator.EQUALS, row.getValue("id"))
|
||||
.withOrderBy(new QFilterOrderBy("createDate")).withOrderBy(new QFilterOrderBy("id"))))
|
||||
{
|
||||
replies.add(entry(reply, List.of()));
|
||||
}
|
||||
result.add(entry(row, replies));
|
||||
}
|
||||
return (new CommentPage(List.copyOf(result), roots.size() > limit ? key(roots.get(limit - 1)).encode() : null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authors alone can edit; administrators cannot rewrite another author's body.
|
||||
*******************************************************************************/
|
||||
public CommentEntry edit(Serializable commentId, String body) throws QException
|
||||
{
|
||||
new PeopleService().touch();
|
||||
QRecord row = requireComment(commentId);
|
||||
requireAuthor(row);
|
||||
requireCommentFeature(row);
|
||||
Set<String> mentions = parse(body);
|
||||
Instant now = Instant.now();
|
||||
CollaborationRecords.insert(CollaborationTables.COMMENT_REVISION, reference(row.getValueString("tableName"), row.getValueString("recordId"))
|
||||
.withValue("commentId", row.getValue("id")).withValue("body", row.getValueString("body")).withValue("editedAt", now)
|
||||
.withValue("editedByUserId", PersonalRecords.currentUserId()));
|
||||
CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("id")).withValue("body", body)
|
||||
.withValue("editedAt", now).withValue("lastActivityAt", now));
|
||||
row.withValue("body", body).withValue("editedAt", now);
|
||||
bumpRoot(row, now);
|
||||
processMentions(row, mentions);
|
||||
ActivityWriter.write(row.getValueString("tableName"), row.getValueString("recordId"), "COMMENT_EDITED", "Comment edited", CollaborationTables.COMMENT, row.getValueString("id"));
|
||||
return (entry(row, List.of()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Soft delete; moderators still need to see the actual parent.
|
||||
*******************************************************************************/
|
||||
public void delete(Serializable commentId) throws QException
|
||||
{
|
||||
new PeopleService().touch();
|
||||
QRecord row = requireComment(commentId);
|
||||
if(!isAdmin())
|
||||
{
|
||||
requireAuthor(row);
|
||||
requireCommentFeature(row);
|
||||
}
|
||||
Instant now = Instant.now();
|
||||
CollaborationRecords.update(CollaborationTables.COMMENT, new QRecord().withValue("id", row.getValue("id"))
|
||||
.withValue("deletedAt", now).withValue("deletedByUserId", PersonalRecords.currentUserId()).withValue("lastActivityAt", now));
|
||||
bumpRoot(row, now);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve only a root thread.
|
||||
*******************************************************************************/
|
||||
public void resolve(Serializable commentId) throws QException
|
||||
{
|
||||
setResolved(commentId, true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reopen only a root thread.
|
||||
*******************************************************************************/
|
||||
public void reopen(Serializable commentId) throws QException
|
||||
{
|
||||
setResolved(commentId, false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Anyone able to comment can change root resolution state.
|
||||
*******************************************************************************/
|
||||
private void setResolved(Serializable commentId, boolean resolved) throws QException
|
||||
{
|
||||
new PeopleService().touch();
|
||||
QRecord row = requireComment(commentId);
|
||||
requireCommentFeature(row);
|
||||
if(row.getValue("threadId") != null)
|
||||
{
|
||||
throw (new 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<CommentRevision> history(Serializable commentId) throws QException
|
||||
{
|
||||
new PeopleService().touch();
|
||||
QRecord row = requireComment(commentId);
|
||||
return (CollaborationRecords.query(CollaborationTables.COMMENT_REVISION, new QQueryFilter().withCriteria("commentId", QCriteriaOperator.EQUALS, row.getValue("id"))
|
||||
.withOrderBy(new QFilterOrderBy("editedAt")).withOrderBy(new QFilterOrderBy("id"))).stream()
|
||||
.map(revision -> new CommentRevision(revision.getValueInteger("id"), revision.getValueString("body"), revision.getValueInstant("editedAt"), revision.getValueString("editedByUserId"))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Rank current participants before other matching active directory users.
|
||||
*******************************************************************************/
|
||||
public List<MentionCandidate> mentionCandidates(String table, Serializable recordId, String q) throws QException
|
||||
{
|
||||
new PeopleService().touch();
|
||||
String id = FollowService.canonicalId(table, recordId);
|
||||
Set<String> participants = new HashSet<>();
|
||||
new FollowService().followers(table, id).forEach(follower -> participants.add(follower.userId()));
|
||||
CollaborationRecords.query(CollaborationTables.COMMENT, CollaborationRecords.parent(table, id)).forEach(row -> participants.add(row.getValueString("authorUserId")));
|
||||
List<PersonEntry> people = new PeopleService().search(q, 50, participants);
|
||||
List<MentionCandidate> result = new ArrayList<>();
|
||||
for(PersonEntry person : people)
|
||||
{
|
||||
result.add(new MentionCandidate(person, new OtherUserAccess().canRead(person.userId(), table, id)));
|
||||
}
|
||||
return (List.copyOf(result));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate before persistence; deduplicate and reject more than fifty mentions.
|
||||
*******************************************************************************/
|
||||
private Set<String> parse(String body) throws QException
|
||||
{
|
||||
if(body == null || body.isBlank() || body.length() > 20000)
|
||||
{
|
||||
throw (new QException("Comment body must contain 1 to 20000 characters."));
|
||||
}
|
||||
Set<String> users = new LinkedHashSet<>();
|
||||
Matcher matcher = MENTION.matcher(body);
|
||||
while(matcher.find())
|
||||
{
|
||||
if(matcher.group(1).length() > 255)
|
||||
{
|
||||
throw (new 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<String> processMentions(QRecord row, Set<String> users) throws QException
|
||||
{
|
||||
Set<String> existing = new HashSet<>();
|
||||
mentionRows(row).forEach(mention -> existing.add(mention.getValueString("mentionedUserId")));
|
||||
Set<String> notified = new HashSet<>();
|
||||
String table = row.getValueString("tableName");
|
||||
String id = row.getValueString("recordId");
|
||||
for(String user : users)
|
||||
{
|
||||
if(existing.contains(user))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
boolean visible = new 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<String> mentioned) throws QException
|
||||
{
|
||||
String author = PersonalRecords.currentUserId();
|
||||
for(FollowEntry follower : new FollowService().followers(row.getValueString("tableName"), row.getValueString("recordId")))
|
||||
{
|
||||
if(!author.equals(follower.userId()) && !mentioned.contains(follower.userId()) && Boolean.TRUE.equals(follower.notifyOnComment())
|
||||
&& (follower.mutedUntil() == null || !follower.mutedUntil().isAfter(Instant.now())))
|
||||
{
|
||||
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on 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<QRecord> rows = CollaborationRecords.query(CollaborationTables.COMMENT, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id));
|
||||
if(rows.isEmpty() || Boolean.TRUE.equals(rows.getFirst().getValueBoolean("parentDeleted")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
QRecord row = rows.getFirst();
|
||||
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Deleted comments cannot be edited by their author.
|
||||
*******************************************************************************/
|
||||
private void requireAuthor(QRecord row) throws QException
|
||||
{
|
||||
if(!PersonalRecords.currentUserId().equals(row.getValueString("authorUserId")) || row.getValue("deletedAt") != null)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Only the author can edit or delete this comment."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared 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<QRecord> mentionRows(QRecord row) throws QException
|
||||
{
|
||||
return (CollaborationRecords.query(CollaborationTables.MENTION, new QQueryFilter().withCriteria("commentId", QCriteriaOperator.EQUALS, row.getValue("id"))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capabilities reflect ownership and the same write permission as mutations.
|
||||
*******************************************************************************/
|
||||
private CommentEntry entry(QRecord row, List<CommentEntry> replies) throws QException
|
||||
{
|
||||
boolean author = PersonalRecords.currentUserId().equals(row.getValueString("authorUserId"));
|
||||
boolean deleted = row.getValue("deletedAt") != null;
|
||||
boolean feature;
|
||||
try
|
||||
{
|
||||
requireCommentFeature(row);
|
||||
feature = true;
|
||||
}
|
||||
catch(QPermissionDeniedException expected)
|
||||
{
|
||||
feature = false;
|
||||
}
|
||||
List<MentionEntry> mentions = mentionRows(row).stream().map(mention -> new MentionEntry(mention.getValueString("mentionedUserId"), mention.getValueBoolean("visible"))).toList();
|
||||
return (new CommentEntry(row.getValueInteger("id"), row.getValueInteger("threadId"), row.getValueInteger("parentCommentId"), deleted ? null : row.getValueString("body"),
|
||||
row.getValueString("fieldName"), row.getValueString("authorUserId"), new PeopleService().displayName(row.getValueString("authorUserId")), row.getValueInstant("createDate"),
|
||||
row.getValueInstant("editedAt"), deleted, row.getValue("resolvedAt") != null, author && feature && !deleted, !deleted && ((author && feature) || isAdmin()), mentions, replies));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Moderation is distinct from permission to edit another author's text.
|
||||
*******************************************************************************/
|
||||
private boolean isAdmin()
|
||||
{
|
||||
return (QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.comments;
|
||||
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.collaboration.people.PersonEntry;
|
||||
|
||||
/*******************************************************************************
|
||||
** Tri-state access is exposed only after the caller can read the parent.
|
||||
*******************************************************************************/
|
||||
public record MentionCandidate(PersonEntry person, Optional<Boolean> canRead)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.comments;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mention visibility is the recipient access result at processing time.
|
||||
*******************************************************************************/
|
||||
public record MentionEntry(String userId, Boolean visible)
|
||||
{
|
||||
}
|
||||
@@ -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<QRecord> 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<FavoriteEntry> 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<Integer> ids, Map<Integer, String> folders) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
List<QRecord> rows = PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.ownFilter()
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")));
|
||||
Set<Integer> owned = new HashSet<>(rows.stream().map(row -> row.getValueInteger("id")).toList());
|
||||
|
||||
@@ -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<String> 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<String> 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<QRecord> batches = CollaborationRecords.query(CollaborationTables.FOLLOW_DIGEST,
|
||||
CollaborationRecords.parent(table, id).withCriteria("userId", QCriteriaOperator.EQUALS, user));
|
||||
Set<String> 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<QRecord> 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)));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
@@ -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<QProcessMetaData> 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<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, (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<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
done.addSelfToListIfAnyCount(lines);
|
||||
if(getTransformStep() instanceof Review review)
|
||||
{
|
||||
review.skipped.addSelfToListIfAnyCount(lines);
|
||||
}
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FollowEntry> state(String table, Serializable recordId) throws QException
|
||||
{
|
||||
new PeopleService().touch();
|
||||
List<QRecord> 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<FollowEntry> 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<QRecord> 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")));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,18 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
*******************************************************************************/
|
||||
public final class CollaborationTables
|
||||
{
|
||||
public static final String SHARE = "collaboration_share";
|
||||
public static final String ACCESS_REQUEST = "collaboration_access_request";
|
||||
public static final String TASK = "collaboration_task";
|
||||
public static final String RECORD_ASSIGNMENT = "collaboration_record_assignment";
|
||||
public static final String TAG = "collaboration_tag";
|
||||
public static final String RECORD_TAG = "collaboration_record_tag";
|
||||
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 +97,12 @@ public final class CollaborationTables
|
||||
public static List<QPossibleValueSource> possibleValueSources()
|
||||
{
|
||||
return (List.of(
|
||||
QPossibleValueSource.newForTable(SHARE), QPossibleValueSource.newForTable(ACCESS_REQUEST),
|
||||
QPossibleValueSource.newForTable(TASK), QPossibleValueSource.newForTable(RECORD_ASSIGNMENT),
|
||||
enumSource("collaboration_access_request_status", "OPEN", "GRANTED", "DECLINED", "WITHDRAWN"),
|
||||
enumSource("collaboration_task_status", "OPEN", "DONE", "CANCELLED"),
|
||||
QPossibleValueSource.newForTable(TAG), QPossibleValueSource.newForTable(RECORD_TAG),
|
||||
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 +170,10 @@ 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<QTableMetaData> tables = List.of(settings, people, favorites, recents, attachment(config));
|
||||
List<QTableMetaData> tables = new ArrayList<>(List.of(settings, people, favorites, recents, attachment(config)));
|
||||
tables.addAll(conversationTables(config));
|
||||
tables.addAll(tagTables(config));
|
||||
tables.addAll(workTables(config));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates"));
|
||||
@@ -252,4 +273,179 @@ 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<QTableMetaData> 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("readAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("notifiedAt", QFieldType.DATE_TIME))
|
||||
.withUniqueKey(new UniqueKey("commentId", "mentionedUserId"));
|
||||
QTableMetaData follows = referenced(config, FOLLOW, "Followers", "notifications")
|
||||
.withField(string("userId", "User", 255).withIsRequired(true))
|
||||
.withField(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<QTableMetaData> 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")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Vocabulary is visible; assignments inherit parent restrictions and locks.
|
||||
*******************************************************************************/
|
||||
private static List<QTableMetaData> tagTables(CollaborationQBitConfig config)
|
||||
{
|
||||
QTableMetaData tags = base(config, TAG, "Tags", "label")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
|
||||
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.tags.TagSecurity.class)))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSearchFields(List.of("label"))
|
||||
.withRecordLabelFormatAndFields("%s", "label")
|
||||
.withField(string("name", "Name", 60).withIsRequired(true))
|
||||
.withField(string("label", "Label", 255).withIsRequired(true))
|
||||
.withField(string("color", "Color", 64))
|
||||
.withField(string("tableName", "Table", 128))
|
||||
.withField(new QFieldMetaData("approved", QFieldType.BOOLEAN).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("description", QFieldType.TEXT))
|
||||
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("usageCount", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(0))
|
||||
.withField(new QFieldMetaData("mergedIntoTagId", QFieldType.INTEGER).withPossibleValueSourceName(TAG))
|
||||
.withUniqueKey(new UniqueKey("tableName", "name"))
|
||||
.withSection(new QFieldSection("details", new QIcon("label"), Tier.T1,
|
||||
List.of("id", "name", "label", "color", "tableName", "approved", "description", "createdByUserId", "usageCount", "mergedIntoTagId")).withLabel("Details"));
|
||||
QTableMetaData links = referenced(config, RECORD_TAG, "Record Tags", "label")
|
||||
.withField(new QFieldMetaData("tagId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(TAG))
|
||||
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
|
||||
.withUniqueKey(new UniqueKey("tableName", "recordId", "tagId"))
|
||||
.withSection(new QFieldSection("details", new QIcon("label"), Tier.T1, List.of("id", "tagId", "createdByUserId")).withLabel("Details"));
|
||||
for(QTableMetaData table : List.of(tags, links))
|
||||
{
|
||||
table.getFields().values().forEach(field -> field.setIsEditable(false));
|
||||
}
|
||||
return (List.of(tags, links));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sharing and work tables remain visible, audited and service-managed.
|
||||
*******************************************************************************/
|
||||
private static List<QTableMetaData> workTables(CollaborationQBitConfig config)
|
||||
{
|
||||
QTableMetaData shares = referenced(config, SHARE, "Shares", "share")
|
||||
.withField(string("fromUserId", "From", 255).withIsRequired(true))
|
||||
.withField(string("toUserId", "To", 255).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000))
|
||||
.withField(new QFieldMetaData("recipientCouldRead", QFieldType.BOOLEAN))
|
||||
.withField(new QFieldMetaData("sentAt", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("openedAt", QFieldType.DATE_TIME));
|
||||
QTableMetaData requests = base(config, ACCESS_REQUEST, "Access Requests", "lock_open")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
|
||||
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.sharing.AccessRequestSecurity.class)))
|
||||
.withRecordSecurityLock(CollaborationSecurity.ownerLock("requesterUserId"))
|
||||
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(com.kingsrook.qbits.collaboration.sharing.AccessRequestSecurity.class))
|
||||
.withRecordLabelFormatAndFields("Access request %s", "id")
|
||||
.withField(string("tableName", "Table", 128).withIsRequired(true))
|
||||
.withField(string("recordId", "Record ID", 64).withIsRequired(true))
|
||||
.withField(string("requesterUserId", "Requester", 255).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("shareId", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000))
|
||||
.withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_access_request_status"))
|
||||
.withField(string("handledByUserId", "Handled By", 255))
|
||||
.withField(new QFieldMetaData("handledAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("note", QFieldType.TEXT).withMaxLength(2000));
|
||||
QTableMetaData tasks = referenced(config, TASK, "Tasks", "task_alt")
|
||||
.withRecordLabelFormatAndFields("%s", "title").withSearchFields(List.of("title"))
|
||||
.withField(string("title", "Title", 200).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("notes", QFieldType.TEXT))
|
||||
.withField(string("assigneeUserId", "Assignee", 255))
|
||||
.withField(new QFieldMetaData("dueAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("remindAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("remindedAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("overdueNotifiedAt", QFieldType.DATE_TIME))
|
||||
.withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_task_status"))
|
||||
.withField(new QFieldMetaData("completedAt", QFieldType.DATE_TIME))
|
||||
.withField(string("completedByUserId", "Completed By", 255))
|
||||
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true));
|
||||
QTableMetaData assignments = referenced(config, RECORD_ASSIGNMENT, "Record Owners", "assignment_ind")
|
||||
.withField(string("ownerUserId", "Owner", 255))
|
||||
.withField(string("assigneeUserId", "Assignee", 255))
|
||||
.withUniqueKey(new UniqueKey("tableName", "recordId"));
|
||||
List<QTableMetaData> tables = List.of(shares, requests, tasks, assignments);
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
table.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("details", new QIcon("info"), Tier.T1,
|
||||
table.getFields().keySet().stream().filter(name -> !List.of("createDate", "modifyDate", "parentDeleted").contains(name)
|
||||
&& (table.getName().equals(ACCESS_REQUEST) || !List.of("tableName", "recordId").contains(name))).toList()).withLabel("Details"));
|
||||
table.getFields().values().forEach(field -> field.setIsEditable(false));
|
||||
}
|
||||
return (tables);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.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)
|
||||
{
|
||||
QJoinMetaData tagJoin = new QJoinMetaData().withLeftTable(CollaborationTables.TAG).withRightTable(CollaborationTables.RECORD_TAG)
|
||||
.withInferredName().withType(JoinType.ONE_TO_MANY).withJoinOns(List.of(new JoinOn("id", "tagId")));
|
||||
instance.addJoin(tagJoin);
|
||||
instance.addWidget(ChildRecordListRenderer.widgetMetaDataBuilder(tagJoin).withLabel("Record Tags").withCanAddChildRecord(false).getWidgetMetaData());
|
||||
instance.getTable(CollaborationTables.TAG).withSection(new QFieldSection("recordTags", new QIcon("list"), Tier.T2)
|
||||
.withLabel("Record Tags").withWidgetName(tagJoin.getName()));
|
||||
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<JoinOn> 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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.mywork;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qbits.collaboration.tasks.TasksService;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Caller-scoped work summary; parent-invisible rows never contribute counts.
|
||||
*******************************************************************************/
|
||||
public class MyWorkService
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Open task count and first ten, assigned record counts, and unread mentions.
|
||||
*******************************************************************************/
|
||||
public Summary summary() throws QException
|
||||
{
|
||||
List<QRecord> tasks = new TasksService().mineRows("OPEN");
|
||||
Map<String, Long> assignments = new LinkedHashMap<>();
|
||||
for(QRecord row : new AssignmentService().mineRows(null, true))
|
||||
{
|
||||
assignments.merge(row.getValueString("tableName"), 1L, Long::sum);
|
||||
}
|
||||
return (new Summary((long) tasks.size(), tasks.stream().limit(10).toList(), assignments, (long) unread().size()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only delivered, unread mentions of currently visible records are actionable.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> unread() throws QException
|
||||
{
|
||||
List<QRecord> result = new ArrayList<>();
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.MENTION, new QQueryFilter()
|
||||
.withCriteria("mentionedUserId", QCriteriaOperator.EQUALS, PersonalRecords.currentUserId())
|
||||
.withCriteria("notifiedAt", QCriteriaOperator.IS_NOT_BLANK).withCriteria("readAt", QCriteriaOperator.IS_BLANK)
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false)))
|
||||
{
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
|
||||
result.add(row);
|
||||
}
|
||||
catch(QNotFoundException expected)
|
||||
{
|
||||
// Do not reveal the existence of mentions on revoked parents.
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Null selects all visible unread mentions; supplied IDs remain owner-scoped.
|
||||
*******************************************************************************/
|
||||
public void markMentionsRead(List<Integer> mentionIds) throws QException
|
||||
{
|
||||
for(QRecord row : unread())
|
||||
{
|
||||
if(mentionIds == null || mentionIds.contains(row.getValueInteger("id")))
|
||||
{
|
||||
CollaborationRecords.update(CollaborationTables.MENTION, new QRecord().withValue("id", row.getValue("id")).withValue("readAt", Instant.now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable service contract for the subsequent UI API PR.
|
||||
*******************************************************************************/
|
||||
public record Summary(Long openTaskCount, List<QRecord> tasks, Map<String, Long> assignedRecordsByTable, Long unreadMentionCount)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 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<QException> receipt) throws QException
|
||||
{
|
||||
send(notification, receipt, true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only for access-request notices containing no parent data or sender message.
|
||||
*******************************************************************************/
|
||||
public static void sendAccessNotice(CollaborationNotification notification) throws QException
|
||||
{
|
||||
send(notification, null, false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Both safe notices and parent-data delivery respect transaction completion.
|
||||
*******************************************************************************/
|
||||
private static void send(CollaborationNotification notification, UnsafeVoidVoidMethod<QException> receipt, boolean checkParent) throws QException
|
||||
{
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
QContext.getQBackendTransaction().addAfterCommitCallback(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> deliver(notification, receipt, checkParent));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Collaboration notification failed after commit", e));
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
deliver(notification, receipt, checkParent);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-check immediately before exposing record data to the transport.
|
||||
*******************************************************************************/
|
||||
private static void deliver(CollaborationNotification notification, UnsafeVoidVoidMethod<QException> receipt, boolean checkParent) throws QException
|
||||
{
|
||||
if(!checkParent || 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 (com.kingsrook.qbits.collaboration.TargetResolver.recordHref(table, id));
|
||||
}
|
||||
}
|
||||
@@ -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<CollaborationNotification> 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<CollaborationNotification> notifications()
|
||||
{
|
||||
return (List.copyOf(notifications));
|
||||
}
|
||||
}
|
||||
@@ -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<QRecord> 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<PersonEntry> 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<PersonEntry> search(String q, Integer limit, Set<String> preferredUserIds) throws QException
|
||||
{
|
||||
PersonalRecords.currentUserId();
|
||||
if(limit == null || limit < 1 || limit > 50)
|
||||
{
|
||||
throw (new QException("Limit must be between 1 and 50"));
|
||||
}
|
||||
List<QRecord> 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<QRecord> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
+1
-1
@@ -29,7 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRule
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full permission names; fields and tags management are reserved for later phases.
|
||||
** Full permission names; fields management is reserved for a later phase.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationPermissions
|
||||
{
|
||||
|
||||
@@ -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<FavoriteEntry> 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"));
|
||||
|
||||
+65
@@ -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.RECORD_ASSIGNMENT) || table.getName().equals(CollaborationTables.FOLLOW) || table.getName().equals(CollaborationTables.ACTIVITY) || table.getName().equals(CollaborationTables.FOLLOW_DIGEST))
|
||||
&& !QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs collaboration.admin."));
|
||||
}
|
||||
if(input instanceof QueryOrCountInputInterface query)
|
||||
{
|
||||
ParentTableRestriction.restrict(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean> 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<QSession> 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<Boolean> 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()));
|
||||
}
|
||||
}
|
||||
@@ -94,10 +94,22 @@ public final class ParentRecordAccess
|
||||
public static void requireFeature(String tableName, Serializable recordId, String feature) throws QException
|
||||
{
|
||||
requireVisibleParent(tableName, recordId);
|
||||
requireTableFeature(tableName, feature);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorize vocabulary creation without requiring an arbitrary parent record.
|
||||
*******************************************************************************/
|
||||
public static void requireTableFeature(String tableName, String feature) throws QException
|
||||
{
|
||||
if(!canReadTable(tableName))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
String base = table.getPermissionRules() == null ? null : table.getPermissionRules().getPermissionBaseName();
|
||||
base = base == null || base.isBlank() ? tableName : base;
|
||||
if(!QContext.getQSession().hasPermission(base + "." + feature)
|
||||
if(!QContext.getQSession().hasPermission(tableName + "." + feature) && !QContext.getQSession().hasPermission(base + "." + feature)
|
||||
&& !PermissionsHelper.hasTablePermission(new AbstractActionInput(), tableName, TablePermissionSubType.EDIT))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the " + base + "." + feature + " permission or table EDIT."));
|
||||
|
||||
@@ -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<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
|
||||
{
|
||||
List<QRecord> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> readable = QContext.getQInstance().getTables().keySet().stream().filter(ParentRecordAccess::canReadTable).toList();
|
||||
QQueryFilter original = input.getFilter();
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("tableName", QCriteriaOperator.IN, readable)
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false);
|
||||
if(original != null)
|
||||
{
|
||||
filter.withSubFilter(original).withLimit(original.getLimit()).withSkip(original.getSkip());
|
||||
filter.setOrderBys(original.getOrderBys());
|
||||
}
|
||||
input.setFilter(filter);
|
||||
}
|
||||
}
|
||||
@@ -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<QSession> sessionFor(String userId) throws QException;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.sharing;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
|
||||
/*******************************************************************************
|
||||
** Requester/admin visibility is independent of parent visibility and existence.
|
||||
*******************************************************************************/
|
||||
public class AccessRequestSecurity implements CustomPermissionChecker, TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Service-only writes prevent forging requester IDs and outcomes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
new CollaborationSecurity().checkPermissionsThrowing(input, metadata);
|
||||
if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Use the access request service actions."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only metadata labels are resolved; never read the referenced parent.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
|
||||
{
|
||||
for(QRecord row : records)
|
||||
{
|
||||
var table = QContext.getQInstance().getTable(row.getValueString("tableName"));
|
||||
row.setDisplayValue("tableName", table == null ? row.getValueString("tableName") : table.getLabel());
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.sharing;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.TargetResolver;
|
||||
import com.kingsrook.qbits.collaboration.WorkSupport;
|
||||
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.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
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.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
/*******************************************************************************
|
||||
** Requests never inspect the parent. Their public result reveals no existence data.
|
||||
*******************************************************************************/
|
||||
public class AccessRequestService
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AccessRequestService.class);
|
||||
private static final AtomicBoolean DIRECTORY_WARNING = new AtomicBoolean();
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist once per open natural key; repeated submissions return the same row.
|
||||
*******************************************************************************/
|
||||
public QRecord request(String table, Serializable recordId, String message, Integer shareId) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
WorkSupport.text(table, 128, true);
|
||||
WorkSupport.text(recordId == null ? null : recordId.toString(), 64, true);
|
||||
WorkSupport.text(message, 2000, false);
|
||||
String id = TargetResolver.recordId("RECORD", table, recordId);
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
|
||||
.withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("requesterUserId", QCriteriaOperator.EQUALS, user)
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "OPEN");
|
||||
List<QRecord> existing = CollaborationRecords.query(CollaborationTables.ACCESS_REQUEST, filter);
|
||||
if(!existing.isEmpty())
|
||||
{
|
||||
return (existing.getFirst());
|
||||
}
|
||||
if(shareId != null)
|
||||
{
|
||||
QRecord share = WorkSupport.row(CollaborationTables.SHARE, shareId);
|
||||
if(!user.equals(share.getValueString("toUserId")) || !table.equals(share.getValueString("tableName")) || !id.equals(share.getValueString("recordId")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
QRecord row;
|
||||
try
|
||||
{
|
||||
row = CollaborationRecords.insert(CollaborationTables.ACCESS_REQUEST, new QRecord().withValue("tableName", table).withValue("recordId", id)
|
||||
.withValue("requesterUserId", user).withValue("message", message).withValue("shareId", shareId).withValue("status", "OPEN"));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
existing = CollaborationRecords.query(CollaborationTables.ACCESS_REQUEST, filter);
|
||||
if(existing.isEmpty())
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
return (existing.getFirst());
|
||||
}
|
||||
notifyApprovers(row);
|
||||
return (row);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve table sharing policy without touching any parent row.
|
||||
*******************************************************************************/
|
||||
public String approverPermission(String table) throws QException
|
||||
{
|
||||
for(QRecord setting : CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter()
|
||||
.withCriteria("scopeType", QCriteriaOperator.EQUALS, "TABLE").withCriteria("scopeName", QCriteriaOperator.EQUALS, table)
|
||||
.withCriteria("feature", QCriteriaOperator.EQUALS, "SHARING")))
|
||||
{
|
||||
String json = setting.getValueString("settings");
|
||||
if(json != null && !json.isBlank())
|
||||
{
|
||||
try
|
||||
{
|
||||
Object permission = JsonUtils.toObject(json, Map.class).get("accessApproverPermission");
|
||||
if(permission != null && !permission.toString().isBlank())
|
||||
{
|
||||
return (permission.toString());
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Invalid SHARING settings JSON", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (CollaborationPermissions.ADMIN);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner lookup uses only the assignment directory, never a parent existence check.
|
||||
*******************************************************************************/
|
||||
private void notifyApprovers(QRecord row) throws QException
|
||||
{
|
||||
Set<String> users = new LinkedHashSet<>();
|
||||
var reference = CollaborationRecords.config().getPermissionHolderDirectory();
|
||||
if(reference == null)
|
||||
{
|
||||
if(DIRECTORY_WARNING.compareAndSet(false, true))
|
||||
{
|
||||
LOG.warn("Access approvers cannot be resolved without PermissionHolderDirectory; only configured record owners will be notified.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
PermissionHolderDirectory directory = QCodeLoader.getAdHoc(PermissionHolderDirectory.class, reference);
|
||||
users.addAll(directory.userIdsWithPermission(CollaborationPermissions.ADMIN));
|
||||
users.addAll(directory.userIdsWithPermission(approverPermission(row.getValueString("tableName"))));
|
||||
}
|
||||
for(QRecord assignment : CollaborationRecords.query(CollaborationTables.RECORD_ASSIGNMENT,
|
||||
CollaborationRecords.parent(row.getValueString("tableName"), row.getValueString("recordId"))))
|
||||
{
|
||||
if(assignment.getValueString("ownerUserId") != null)
|
||||
{
|
||||
users.add(assignment.getValueString("ownerUserId"));
|
||||
}
|
||||
}
|
||||
for(String user : users)
|
||||
{
|
||||
notice(user, "Record access requested", row.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Requesters can only withdraw their own still-open request.
|
||||
*******************************************************************************/
|
||||
public void withdraw(Integer requestId) throws QException
|
||||
{
|
||||
QRecord row = WorkSupport.row(CollaborationTables.ACCESS_REQUEST, requestId);
|
||||
if(!PersonalRecords.currentUserId().equals(row.getValueString("requesterUserId")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
requireOpen(row);
|
||||
CollaborationRecords.update(CollaborationTables.ACCESS_REQUEST, new QRecord().withValue("id", requestId).withValue("status", "WITHDRAWN"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Approver permissions authorize only recording an outcome, never an access grant.
|
||||
*******************************************************************************/
|
||||
public QRecord requireApprover(Integer requestId) throws QException
|
||||
{
|
||||
PersonalRecords.currentUserId();
|
||||
QRecord row = WorkSupport.row(CollaborationTables.ACCESS_REQUEST, requestId);
|
||||
if(!QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN)
|
||||
&& !QContext.getQSession().hasPermission(approverPermission(row.getValueString("tableName"))))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the access approver permission."));
|
||||
}
|
||||
requireOpen(row);
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The host identity system is responsible for any actual permission change.
|
||||
*******************************************************************************/
|
||||
public void handle(Integer requestId, String status, String note) throws QException
|
||||
{
|
||||
WorkSupport.text(note, 2000, false);
|
||||
if(!List.of("GRANTED", "DECLINED").contains(status))
|
||||
{
|
||||
throw (new QException("Choose GRANTED or DECLINED."));
|
||||
}
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
QRecord row = requireApprover(requestId);
|
||||
CollaborationRecords.update(CollaborationTables.ACCESS_REQUEST, new QRecord().withValue("id", requestId).withValue("status", status)
|
||||
.withValue("note", note).withValue("handledAt", Instant.now()).withValue("handledByUserId", PersonalRecords.currentUserId()));
|
||||
notice(row.getValueString("requesterUserId"), "Access request " + status.toLowerCase(java.util.Locale.ROOT), requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Terminal outcomes cannot be overwritten accidentally.
|
||||
*******************************************************************************/
|
||||
private void requireOpen(QRecord row) throws QException
|
||||
{
|
||||
if(!"OPEN".equals(row.getValueString("status")))
|
||||
{
|
||||
throw (new QException("This access request is no longer open."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Notices contain no record label, request message or approver note.
|
||||
*******************************************************************************/
|
||||
private void notice(String user, String title, Integer id) throws QException
|
||||
{
|
||||
Notifications.sendAccessNotice(new CollaborationNotification(user, title, "Review the access request.",
|
||||
CollaborationNotification.Category.SHARE, null, null, "/app/collaboration_access_request/" + id));
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.sharing;
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional host directory; membership resolution never grants record access.
|
||||
*******************************************************************************/
|
||||
public interface PermissionHolderDirectory
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Return stable user IDs holding this permission.
|
||||
*******************************************************************************/
|
||||
Set<String> userIdsWithPermission(String permission) throws QException;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.sharing;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
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.TargetResolver;
|
||||
import com.kingsrook.qbits.collaboration.WorkSupport;
|
||||
import com.kingsrook.qbits.collaboration.activity.ActivityWriter;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
|
||||
import com.kingsrook.qbits.collaboration.notify.Notifications;
|
||||
import com.kingsrook.qbits.collaboration.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.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Shares communicate intent; they never change permissions.
|
||||
*******************************************************************************/
|
||||
public class SharingService
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Canonical link and a fresh label resolved as the caller.
|
||||
*******************************************************************************/
|
||||
public Link link(String table, Serializable recordId) throws QException
|
||||
{
|
||||
ParentRecordAccess.requireVisibleParent(table, recordId);
|
||||
var target = TargetResolver.require("RECORD", table, recordId);
|
||||
return (new Link(TargetResolver.recordHref(table, target.recordId()), target.label()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unknown recipient access is represented by null, never converted to true.
|
||||
*******************************************************************************/
|
||||
public List<Recipient> preview(String table, Serializable recordId, List<String> toUserIds) throws QException
|
||||
{
|
||||
new PeopleService().touch();
|
||||
ParentRecordAccess.requireFeature(table, recordId, "share");
|
||||
if(toUserIds == null || toUserIds.isEmpty() || toUserIds.size() > 25 || toUserIds.stream().anyMatch(user -> user == null || user.isBlank()))
|
||||
{
|
||||
throw (new QException("Choose 1 to 25 recipients."));
|
||||
}
|
||||
List<Recipient> recipients = new ArrayList<>();
|
||||
for(String user : toUserIds.stream().distinct().toList())
|
||||
{
|
||||
Optional<Boolean> access = user.equals(PersonalRecords.currentUserId()) ? Optional.of(true) : new OtherUserAccess().canRead(user, table, recordId);
|
||||
recipients.add(new Recipient(user, new PeopleService().displayName(user), access.orElse(null)));
|
||||
}
|
||||
return (recipients);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Nonreaders get neither the record label nor the sender's untrusted message.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> share(String table, Serializable recordId, List<String> toUserIds, String message) throws QException
|
||||
{
|
||||
WorkSupport.text(message, 2000, false);
|
||||
List<Recipient> recipients = preview(table, recordId, toUserIds);
|
||||
String id = FollowService.canonicalId(table, recordId);
|
||||
Link link = link(table, id);
|
||||
String from = PersonalRecords.currentUserId();
|
||||
String name = new PeopleService().displayName(from);
|
||||
List<QRecord> result = new ArrayList<>();
|
||||
for(Recipient recipient : recipients)
|
||||
{
|
||||
QRecord row = CollaborationRecords.insert(CollaborationTables.SHARE, new QRecord().withValue("tableName", table).withValue("recordId", id)
|
||||
.withValue("fromUserId", from).withValue("toUserId", recipient.userId()).withValue("message", message)
|
||||
.withValue("recipientCouldRead", recipient.canRead()).withValue("sentAt", Instant.now()).withValue("parentDeleted", false));
|
||||
if(Boolean.TRUE.equals(recipient.canRead()))
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared " + link.label() + " with you", message,
|
||||
CollaborationNotification.Category.SHARE, table, id, link.url()));
|
||||
new FollowService().ensureFollower(recipient.userId(), table, id, "SHARED");
|
||||
}
|
||||
else
|
||||
{
|
||||
Notifications.sendAccessNotice(new CollaborationNotification(recipient.userId(), name + " wants to share a record in "
|
||||
+ QContext.getQInstance().getTable(table).getLabel() + " with you", "Request access to open this record.",
|
||||
CollaborationNotification.Category.SHARE, null, null, "/app/collaboration_access_request/create?tableName="
|
||||
+ TargetResolver.encode(table) + "&recordId=" + TargetResolver.encode(id) + "&shareId=" + row.getValueString("id")));
|
||||
}
|
||||
ActivityWriter.write(table, id, "SHARED", "Record shared", CollaborationTables.SHARE, row.getValueString("id"));
|
||||
result.add(row);
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The intended recipient may acknowledge even a share of an invisible parent.
|
||||
*******************************************************************************/
|
||||
public void markOpened(Integer shareId) throws QException
|
||||
{
|
||||
String user = PersonalRecords.currentUserId();
|
||||
QRecord row = WorkSupport.row(CollaborationTables.SHARE, shareId);
|
||||
if(!user.equals(row.getValueString("toUserId")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
if(row.getValueInstant("openedAt") == null)
|
||||
{
|
||||
CollaborationRecords.update(CollaborationTables.SHARE, new QRecord().withValue("id", shareId).withValue("openedAt", Instant.now()));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Link payload for the later UI API adapter.
|
||||
*******************************************************************************/
|
||||
public record Link(String url, String label)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A nullable Boolean distinguishes unavailable access knowledge from denial.
|
||||
*******************************************************************************/
|
||||
public record Recipient(String userId, String displayName, Boolean canRead)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.tags;
|
||||
|
||||
/*******************************************************************************
|
||||
** A vocabulary entry or record chip, without exposing assignment rows.
|
||||
*******************************************************************************/
|
||||
public record TagEntry(Integer id, String name, String label, String color, String tableName, Boolean approved, Integer usageCount)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.tags;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Materialized tag filter logic independent of the unreleased core hooks in #1140.
|
||||
** Every operator returns matching caller-visible parent ids, including complements.
|
||||
*******************************************************************************/
|
||||
public class TagFilterTranslator
|
||||
{
|
||||
public static final int MAX_RECORD_IDS = 10000;
|
||||
|
||||
/*******************************************************************************
|
||||
** IN means any tag, CONTAINS_ALL means every requested tag, NOT_IN means none.
|
||||
** Empty IN matches none; empty CONTAINS_ALL and NOT_IN match all visible rows.
|
||||
*******************************************************************************/
|
||||
public List<String> recordIds(String table, String op, Collection<? extends Serializable> tags) throws QException
|
||||
{
|
||||
TagsService.requireTable(table);
|
||||
if(!List.of("IN", "CONTAINS_ALL", "NOT_IN", "IS_BLANK", "IS_NOT_BLANK").contains(op))
|
||||
{
|
||||
throw (new QException("Unsupported tag operator: " + op));
|
||||
}
|
||||
Set<Integer> requested = new HashSet<>();
|
||||
boolean missing = false;
|
||||
if(!op.equals("IS_BLANK") && !op.equals("IS_NOT_BLANK"))
|
||||
{
|
||||
for(Serializable tag : tags == null ? List.<Serializable>of() : tags)
|
||||
{
|
||||
QQueryFilter filter = TagsService.vocabularyFilter(table).withCriteria("approved", QCriteriaOperator.EQUALS, true);
|
||||
filter.withCriteria(tag instanceof Number ? "id" : "name", QCriteriaOperator.EQUALS,
|
||||
tag instanceof Number ? tag : TagsService.normalize(tag == null ? null : tag.toString()));
|
||||
List<QRecord> matches = CollaborationRecords.query(CollaborationTables.TAG, filter.withOrderBy(new QFilterOrderBy("id")));
|
||||
if(matches.isEmpty())
|
||||
{
|
||||
missing = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
QRecord match = matches.stream().filter(row -> row.getValue("tableName") == null).findFirst().orElse(matches.getFirst());
|
||||
requested.add(match.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, Set<Integer>> assignments = new HashMap<>();
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.RECORD_TAG, new QQueryFilter()
|
||||
.withCriteria("tableName", QCriteriaOperator.EQUALS, table).withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false)))
|
||||
{
|
||||
assignments.computeIfAbsent(row.getValueString("recordId"), ignored -> new HashSet<>()).add(row.getValueInteger("tagId"));
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
String key = QContext.getQInstance().getTable(table).getPrimaryKeyField();
|
||||
for(int skip = 0; ; skip += 500)
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy(key)).withSkip(skip).withLimit(500))
|
||||
.withTransaction(QContext.getQBackendTransaction());
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
List<QRecord> parents = new QueryAction().execute(input).getRecords();
|
||||
for(QRecord parent : parents)
|
||||
{
|
||||
String id = parent.getValueString(key);
|
||||
Set<Integer> applied = assignments.getOrDefault(id, Set.of());
|
||||
boolean any = requested.stream().anyMatch(applied::contains);
|
||||
boolean match = switch(op)
|
||||
{
|
||||
case "IN" -> any;
|
||||
case "NOT_IN" -> !any;
|
||||
case "CONTAINS_ALL" -> !missing && applied.containsAll(requested);
|
||||
case "IS_BLANK" -> applied.isEmpty();
|
||||
default -> !applied.isEmpty();
|
||||
};
|
||||
if(match)
|
||||
{
|
||||
result.add(id);
|
||||
if(result.size() > MAX_RECORD_IDS)
|
||||
{
|
||||
throw (new QException("Tag filter exceeds 10,000 matching record ids."));
|
||||
}
|
||||
}
|
||||
}
|
||||
if(parents.size() < 500)
|
||||
{
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.tags;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
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.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
/*******************************************************************************
|
||||
** Managed vocabulary and host bulk actions using the standard streamed ETL flow.
|
||||
*******************************************************************************/
|
||||
public final class TagProcesses
|
||||
{
|
||||
public static final String TAGS = "tags";
|
||||
public static final String TARGET = "targetTagId";
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata factory.
|
||||
*******************************************************************************/
|
||||
private TagProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Vocabulary record actions, never navigation entries.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> management()
|
||||
{
|
||||
return (List.of(process(CollaborationTables.TAG, "Approve"), process(CollaborationTables.TAG, "Merge"), process(CollaborationTables.TAG, "Delete")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host record actions configured by tagActionTables.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processesFor(String table)
|
||||
{
|
||||
return (List.of(process(table, "Add"), process(table, "Remove")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Approved scoped PVS inputs and explicit selection cardinality for clients.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData process(String table, String operation)
|
||||
{
|
||||
boolean bulk = !table.equals(CollaborationTables.TAG);
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, operation + " tags on the selected records.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values)
|
||||
.withName("collaboration" + operation + "Tags" + (bulk ? "_" + table : "")).withLabel(operation + " tags").withTableName(table).withIcon(new QIcon("label"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(bulk
|
||||
? UseTablePermissionCustomPermissionChecker.build(table, TablePermissionSubType.READ) : new QCodeReference(TagSecurity.class)));
|
||||
if(bulk || operation.equals("Merge"))
|
||||
{
|
||||
QQueryFilter filter = bulk ? TagsService.vocabularyFilter(table) : new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK);
|
||||
filter.withCriteria("approved", QCriteriaOperator.EQUALS, true).withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label"));
|
||||
QFieldMetaData field = new QFieldMetaData(bulk ? TAGS : TARGET, QFieldType.INTEGER).withLabel(bulk ? "Tags" : "Target tag")
|
||||
.withIsRequired(true).withPossibleValueSourceName(CollaborationTables.TAG).withPossibleValueSourceFilter(filter);
|
||||
process.withStep(0, new QFrontendStepMetaData().withName("tags").withLabel(operation + " tags").withFormField(field)
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)
|
||||
.withValue("multiSelectFields", new ArrayList<>(bulk ? List.of(TAGS) : List.of()))));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Operation comes from installed metadata, never caller-supplied values.
|
||||
*******************************************************************************/
|
||||
private static String operation(RunBackendStepInput input)
|
||||
{
|
||||
return (input.getProcess().getName().substring("collaboration".length()).split("Tags")[0]);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** PVS submissions are ids; accept serialized comma lists as well as arrays.
|
||||
*******************************************************************************/
|
||||
private static List<Integer> tagIds(RunBackendStepInput input) throws QException
|
||||
{
|
||||
Serializable value = input.getValue(TAGS);
|
||||
Collection<?> values = value instanceof Collection<?> collection ? collection : java.util.Arrays.asList(value == null ? new String[0] : value.toString().split(","));
|
||||
List<Integer> ids = new ArrayList<>();
|
||||
try
|
||||
{
|
||||
for(Object id : values)
|
||||
{
|
||||
ids.add(Integer.valueOf(id.toString().strip()));
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException expected)
|
||||
{
|
||||
throw (new QException("Pick at least one approved tag."));
|
||||
}
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
throw (new QException("Pick at least one approved tag."));
|
||||
}
|
||||
return (ids.stream().distinct().toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared renderer for Review and Result language.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String future, String past)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(future);
|
||||
line.setPluralFutureMessage(future);
|
||||
line.setSingularPastMessage(past);
|
||||
line.setPluralPastMessage(past);
|
||||
return (line);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate each record without mutation, reporting affected and unchanged rows.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private ProcessSummaryLine selected;
|
||||
private final ProcessSummaryLine skipped = line(Status.WARNING, "records cannot be processed (skipped)", "records skipped");
|
||||
private final ProcessSummaryLine unchanged = line(Status.INFO, "records already in the requested tag state", "records already in the requested tag state");
|
||||
private final ProcessSummaryLine affected = line(Status.INFO, "records will lose tag assignments", "records affected by tag deletion");
|
||||
private final Set<String> affectedParents = new HashSet<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Parent and feature checks are made independently for every selected row.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String table = input.getProcess().getTableName();
|
||||
String op = operation(input);
|
||||
boolean bulk = !table.equals(CollaborationTables.TAG);
|
||||
List<Integer> ids = bulk ? tagIds(input) : List.of();
|
||||
if(selected == null)
|
||||
{
|
||||
selected = line(Status.OK, "records: " + op + " " + (bulk ? ids.size() + " " : "") + "tags", "records: " + op + " tags completed");
|
||||
}
|
||||
String key = QContext.getQInstance().getTable(table).getPrimaryKeyField();
|
||||
TagsService service = new TagsService();
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
Serializable id = row.getValue(key);
|
||||
try
|
||||
{
|
||||
if(bulk)
|
||||
{
|
||||
ParentRecordAccess.requireFeature(table, id, "tag");
|
||||
service.resolve(table, ids, false);
|
||||
Set<Integer> present = new HashSet<>(service.tagsFor(table, id).stream().map(TagEntry::id).toList());
|
||||
if(op.equals("Add") ? present.containsAll(ids) : ids.stream().noneMatch(present::contains))
|
||||
{
|
||||
unchanged.incrementCountAndAddPrimaryKey(id);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
TagsService.requireManage();
|
||||
TagsService.requireTag(id);
|
||||
if(op.equals("Merge"))
|
||||
{
|
||||
service.mergeTarget(id, input.getValue(TARGET));
|
||||
}
|
||||
if(op.equals("Delete"))
|
||||
{
|
||||
for(QRecord link : TagsService.links(id))
|
||||
{
|
||||
String parent = link.getValueString("tableName") + ":" + link.getValueString("recordId");
|
||||
if(affectedParents.add(parent))
|
||||
{
|
||||
affected.incrementCountAndAddPrimaryKey(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
output.getRecords().add(row);
|
||||
selected.incrementCountAndAddPrimaryKey(id);
|
||||
}
|
||||
catch(QException expected)
|
||||
{
|
||||
skipped.incrementCountAndAddPrimaryKey(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review totals are cumulative over streamed pages.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
if(selected != null)
|
||||
{
|
||||
selected.addSelfToListIfAnyCount(lines);
|
||||
}
|
||||
skipped.addSelfToListIfAnyCount(lines);
|
||||
unchanged.addSelfToListIfAnyCount(lines);
|
||||
affected.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with fresh authorization; report actual successes and failures.
|
||||
*******************************************************************************/
|
||||
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine done = line(Status.OK, "records will be processed", "records processed");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "records cannot be processed", "records failed (retry required)");
|
||||
|
||||
/*******************************************************************************
|
||||
** The management service and parent service both recheck access at load time.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String table = input.getProcess().getTableName();
|
||||
String key = QContext.getQInstance().getTable(table).getPrimaryKeyField();
|
||||
TagsService service = new TagsService();
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
Serializable id = row.getValue(key);
|
||||
try
|
||||
{
|
||||
switch(operation(input))
|
||||
{
|
||||
case "Approve" -> service.approve(id);
|
||||
case "Merge" -> service.merge(id, input.getValue(TARGET));
|
||||
case "Delete" -> service.delete(id);
|
||||
case "Add" -> service.apply(table, id, tagIds(input), List.of());
|
||||
case "Remove" -> service.apply(table, id, List.of(), tagIds(input));
|
||||
default -> throw new QException("Unknown tag action.");
|
||||
}
|
||||
done.incrementCountAndAddPrimaryKey(id);
|
||||
}
|
||||
catch(QException expected)
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Result retains review skips and reports load failures separately.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
done.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
if(getTransformStep() instanceof Review review)
|
||||
{
|
||||
review.skipped.addSelfToListIfAnyCount(lines);
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.tags;
|
||||
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
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.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.tables.QueryOrCountInputInterface;
|
||||
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.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Vocabulary reads support pickers without collaboration.view. Management actions
|
||||
** require the explicit manage permission; direct writes must use the services.
|
||||
*******************************************************************************/
|
||||
public class TagSecurity implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Restrict before query/count/aggregate and protect direct gets as well.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(metadata instanceof QProcessMetaData)
|
||||
{
|
||||
TagsService.requireManage();
|
||||
return;
|
||||
}
|
||||
if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Use the tag service actions."));
|
||||
}
|
||||
if(TagsService.canManage())
|
||||
{
|
||||
return;
|
||||
}
|
||||
String user = CollaborationSecurity.userId(QContext.getQSession());
|
||||
if(user == null)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Sign in to view tags."));
|
||||
}
|
||||
if(input instanceof QueryOrCountInputInterface query)
|
||||
{
|
||||
QQueryFilter original = query.getFilter();
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK)
|
||||
.withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("tableName", QCriteriaOperator.IS_BLANK)
|
||||
.withCriteria("tableName", QCriteriaOperator.IN, QContext.getQInstance().getTables().keySet().stream().filter(ParentRecordAccess::canReadTable).toList()))
|
||||
.withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("approved", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("createdByUserId", QCriteriaOperator.EQUALS, user));
|
||||
if(original != null)
|
||||
{
|
||||
filter.withSubFilter(original).withLimit(original.getLimit()).withSkip(original.getSkip());
|
||||
filter.setOrderBys(original.getOrderBys());
|
||||
}
|
||||
query.setFilter(filter);
|
||||
}
|
||||
if(input instanceof GetInput get)
|
||||
{
|
||||
try
|
||||
{
|
||||
QRecord row = TagsService.requireTag(get.getPrimaryKey());
|
||||
if((row.getValue("tableName") != null && !ParentRecordAccess.canReadTable(row.getValueString("tableName")))
|
||||
|| (!Boolean.TRUE.equals(row.getValueBoolean("approved")) && !user.equals(row.getValueString("createdByUserId"))))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Tag not available."));
|
||||
}
|
||||
}
|
||||
catch(QException expected)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Tag not available."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.tags;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
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.security.ParentRecordAccess;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.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;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
/*******************************************************************************
|
||||
** Tag vocabulary and assignments. Parent authorization precedes trusted storage.
|
||||
** Mutations join the caller's backend transaction; the config lock serializes
|
||||
** service calls within this instance, as in the existing collaboration services.
|
||||
*******************************************************************************/
|
||||
public class TagsService
|
||||
{
|
||||
public static final String MANAGE = com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions.TAGS_MANAGE;
|
||||
|
||||
/*******************************************************************************
|
||||
** Case-independent natural key; labels retain the user's capitalization.
|
||||
*******************************************************************************/
|
||||
public static String normalize(String label) throws QException
|
||||
{
|
||||
String name = label == null ? "" : label.strip().toLowerCase(Locale.ROOT).replaceAll("(?U)\\s+", " ").strip();
|
||||
if(name.isEmpty() || name.length() > 60)
|
||||
{
|
||||
throw (new QException("Tag names must contain 1 to 60 characters."));
|
||||
}
|
||||
return (name);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Global and table vocabulary; pending suggestions are private to their author.
|
||||
*******************************************************************************/
|
||||
public List<TagEntry> vocabulary(String table, String q, Integer limit) throws QException
|
||||
{
|
||||
requireTable(table);
|
||||
QQueryFilter filter = vocabularyFilter(table).withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("approved", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("createdByUserId", QCriteriaOperator.EQUALS, PersonalRecords.currentUserId()));
|
||||
if(q != null && !q.isBlank())
|
||||
{
|
||||
filter.withCriteria("name", QCriteriaOperator.CONTAINS, q.strip().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
filter.withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(limit == null ? 20 : Math.max(1, Math.min(100, limit)));
|
||||
return (CollaborationRecords.query(CollaborationTables.TAG, filter).stream().map(TagsService::entry).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared picker filter, also enforced when submitted values reach the service.
|
||||
*******************************************************************************/
|
||||
public static QQueryFilter vocabularyFilter(String table)
|
||||
{
|
||||
QQueryFilter scope = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("tableName", QCriteriaOperator.IS_BLANK);
|
||||
if(table != null)
|
||||
{
|
||||
scope.withCriteria("tableName", QCriteriaOperator.EQUALS, table);
|
||||
}
|
||||
return (new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK).withSubFilter(scope));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve persisted policy as system, preserving the caller and transaction.
|
||||
** The preferred app path supplies the APP scope when a table has several apps.
|
||||
*******************************************************************************/
|
||||
public String mode(String table) throws QException
|
||||
{
|
||||
List<String> resolved = new ArrayList<>();
|
||||
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), new QSystemUserSession(), QContext.getQBackendTransaction(), null), () ->
|
||||
{
|
||||
String path = QContext.getQInstance().getTablePath(table);
|
||||
String[] parts = path == null ? new String[0] : path.split("/");
|
||||
String app = parts.length > 2 ? parts[parts.length - 2] : null;
|
||||
for(String scope : List.of("TABLE", "APP", "INSTANCE"))
|
||||
{
|
||||
if(scope.equals("APP") && app == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("feature", QCriteriaOperator.EQUALS, "TAGS")
|
||||
.withCriteria("scopeType", QCriteriaOperator.EQUALS, scope);
|
||||
if(!scope.equals("INSTANCE"))
|
||||
{
|
||||
filter.withCriteria("scopeName", QCriteriaOperator.EQUALS, scope.equals("TABLE") ? table : app);
|
||||
}
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, filter))
|
||||
{
|
||||
String json = row.getValueString("settings");
|
||||
if(json != null && !json.isBlank())
|
||||
{
|
||||
Object value = settingMode(json);
|
||||
if(value != null)
|
||||
{
|
||||
String mode = value.toString();
|
||||
if(!List.of("FREE", "APPROVED_ONLY").contains(mode))
|
||||
{
|
||||
throw (new QException("Invalid TAGS mode: " + mode));
|
||||
}
|
||||
resolved.add(mode);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return (resolved.isEmpty() ? CollaborationRecords.config().getDefaultTagMode() : resolved.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid JSON fails closed instead of silently relaxing an approval policy.
|
||||
*******************************************************************************/
|
||||
private Object settingMode(String json) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JsonUtils.toObject(json, Map.class).get("mode"));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Invalid TAGS settings JSON", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Create an approved free tag or a pending suggestion. Globals require manage.
|
||||
*******************************************************************************/
|
||||
public TagEntry create(String table, String label, String color) throws QException
|
||||
{
|
||||
if(table == null)
|
||||
{
|
||||
requireManage();
|
||||
}
|
||||
else
|
||||
{
|
||||
ParentRecordAccess.requireTableFeature(table, "tag");
|
||||
}
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
String name = normalize(label);
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("name", QCriteriaOperator.EQUALS, name);
|
||||
filter.withCriteria("tableName", table == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, table);
|
||||
List<QRecord> existing = CollaborationRecords.query(CollaborationTables.TAG, filter);
|
||||
if(!existing.isEmpty())
|
||||
{
|
||||
QRecord row = existing.getFirst();
|
||||
if(row.getValue("mergedIntoTagId") != null || (!Boolean.TRUE.equals(row.getValueBoolean("approved"))
|
||||
&& !PersonalRecords.currentUserId().equals(row.getValueString("createdByUserId")) && !canManage()))
|
||||
{
|
||||
throw (new QException("Tag name is already reserved."));
|
||||
}
|
||||
return (entry(row));
|
||||
}
|
||||
boolean approved = table == null || canManage() || mode(table).equals("FREE");
|
||||
return (entry(CollaborationRecords.insert(CollaborationTables.TAG, new QRecord().withValue("name", name)
|
||||
.withValue("label", label.strip()).withValue("color", color).withValue("tableName", table).withValue("approved", approved)
|
||||
.withValue("createdByUserId", PersonalRecords.currentUserId()).withValue("usageCount", 0))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read chips only after resolving the canonical, visible parent key.
|
||||
*******************************************************************************/
|
||||
public List<TagEntry> tagsFor(String table, Serializable recordId) throws QException
|
||||
{
|
||||
String id = parentId(table, recordId);
|
||||
List<Integer> ids = assignments(table, id).stream().map(row -> row.getValueInteger("tagId")).toList();
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
return (CollaborationRecords.query(CollaborationTables.TAG, vocabularyFilter(table).withCriteria("id", QCriteriaOperator.IN, ids)
|
||||
.withCriteria("approved", QCriteriaOperator.EQUALS, true).withOrderBy(new QFilterOrderBy("label"))).stream().map(TagsService::entry).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve and validate all inputs before changing assignments. Numbers are ids;
|
||||
** strings are labels (including numeric labels), with global matches preferred.
|
||||
*******************************************************************************/
|
||||
List<QRecord> resolve(String table, Collection<? extends Serializable> values, boolean createMissing) throws QException
|
||||
{
|
||||
Map<Integer, QRecord> result = new LinkedHashMap<>();
|
||||
for(Serializable value : values == null ? List.<Serializable>of() : values)
|
||||
{
|
||||
QQueryFilter filter = vocabularyFilter(table);
|
||||
if(value instanceof Number)
|
||||
{
|
||||
filter.withCriteria("id", QCriteriaOperator.EQUALS, value);
|
||||
}
|
||||
else
|
||||
{
|
||||
filter.withCriteria("name", QCriteriaOperator.EQUALS, normalize(Objects.toString(value, null)));
|
||||
}
|
||||
List<QRecord> matches = CollaborationRecords.query(CollaborationTables.TAG, filter.withOrderBy(new QFilterOrderBy("id")));
|
||||
QRecord row;
|
||||
if(matches.isEmpty())
|
||||
{
|
||||
if(!createMissing || value instanceof Number || !mode(table).equals("FREE"))
|
||||
{
|
||||
throw (new QException("Pick an approved tag (or suggest one)"));
|
||||
}
|
||||
row = requireTag(create(table, value.toString(), null).id());
|
||||
}
|
||||
else
|
||||
{
|
||||
row = matches.stream().filter(tag -> tag.getValue("tableName") == null).findFirst().orElse(matches.getFirst());
|
||||
}
|
||||
if(!Boolean.TRUE.equals(row.getValueBoolean("approved")))
|
||||
{
|
||||
throw (new QException("Pick an approved tag (or suggest one)"));
|
||||
}
|
||||
result.put(row.getValueInteger("id"), row);
|
||||
}
|
||||
return (new ArrayList<>(result.values()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Idempotent assignment changes, with activity only for actual changes.
|
||||
** Tags intentionally do not notify followers.
|
||||
*******************************************************************************/
|
||||
public void apply(String table, Serializable recordId, Collection<? extends Serializable> add, Collection<? extends Serializable> remove) throws QException
|
||||
{
|
||||
ParentRecordAccess.requireFeature(table, recordId, "tag");
|
||||
String id = parentId(table, recordId);
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
List<QRecord> removals = resolve(table, remove, false);
|
||||
List<QRecord> additions = resolve(table, add, true);
|
||||
for(QRecord tag : removals)
|
||||
{
|
||||
List<QRecord> links = assignments(table, id).stream().filter(row -> Objects.equals(row.getValue("tagId"), tag.getValue("id"))).toList();
|
||||
for(QRecord link : links)
|
||||
{
|
||||
CollaborationRecords.delete(CollaborationTables.RECORD_TAG, link.getValue("id"));
|
||||
ActivityWriter.write(table, id, "UNTAGGED", "Tag removed", CollaborationTables.TAG, tag.getValueString("id"));
|
||||
}
|
||||
recompute(tag.getValueInteger("id"));
|
||||
}
|
||||
for(QRecord tag : additions)
|
||||
{
|
||||
if(assignments(table, id).stream().noneMatch(row -> Objects.equals(row.getValue("tagId"), tag.getValue("id"))))
|
||||
{
|
||||
CollaborationRecords.insert(CollaborationTables.RECORD_TAG, new QRecord().withValue("tableName", table).withValue("recordId", id)
|
||||
.withValue("tagId", tag.getValue("id")).withValue("createdByUserId", PersonalRecords.currentUserId()).withValue("parentDeleted", false));
|
||||
ActivityWriter.write(table, id, "TAGGED", "Tag added", CollaborationTables.TAG, tag.getValueString("id"));
|
||||
}
|
||||
recompute(tag.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Approval never changes an already merged alias.
|
||||
*******************************************************************************/
|
||||
public void approve(Serializable id) throws QException
|
||||
{
|
||||
requireManage();
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
requireTag(id);
|
||||
CollaborationRecords.update(CollaborationTables.TAG, new QRecord().withValue("id", id).withValue("approved", true));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate a merge before review and again before load; prevent scope loss.
|
||||
*******************************************************************************/
|
||||
public QRecord mergeTarget(Serializable sourceId, Serializable targetId) throws QException
|
||||
{
|
||||
requireManage();
|
||||
QRecord source = requireTag(sourceId);
|
||||
QRecord target = requireTag(targetId);
|
||||
if(Objects.equals(source.getValue("id"), target.getValue("id")) || !Boolean.TRUE.equals(target.getValueBoolean("approved"))
|
||||
|| (target.getValue("tableName") != null && !Objects.equals(source.getValue("tableName"), target.getValue("tableName"))))
|
||||
{
|
||||
throw (new QException("Choose a different approved tag in the same scope or global vocabulary."));
|
||||
}
|
||||
return (target);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Move references with deduplication and retain the source as an audited alias.
|
||||
*******************************************************************************/
|
||||
public void merge(Serializable sourceId, Serializable targetId) throws QException
|
||||
{
|
||||
requireManage();
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
QRecord target = mergeTarget(sourceId, targetId);
|
||||
for(QRecord link : links(sourceId))
|
||||
{
|
||||
boolean duplicate = assignments(link.getValueString("tableName"), link.getValueString("recordId")).stream()
|
||||
.anyMatch(row -> Objects.equals(row.getValue("tagId"), target.getValue("id")));
|
||||
if(duplicate)
|
||||
{
|
||||
CollaborationRecords.delete(CollaborationTables.RECORD_TAG, link.getValue("id"));
|
||||
}
|
||||
else
|
||||
{
|
||||
CollaborationRecords.update(CollaborationTables.RECORD_TAG, new QRecord().withValue("id", link.getValue("id")).withValue("tagId", target.getValue("id")));
|
||||
}
|
||||
}
|
||||
CollaborationRecords.update(CollaborationTables.TAG, new QRecord().withValue("id", sourceId).withValue("mergedIntoTagId", target.getValue("id")));
|
||||
recompute(sourceId);
|
||||
recompute(targetId);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete the vocabulary entry and its assignments through the managed action.
|
||||
*******************************************************************************/
|
||||
public void delete(Serializable id) throws QException
|
||||
{
|
||||
requireManage();
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
for(QRecord link : links(id))
|
||||
{
|
||||
CollaborationRecords.delete(CollaborationTables.RECORD_TAG, link.getValue("id"));
|
||||
}
|
||||
CollaborationRecords.delete(CollaborationTables.TAG, id);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted parent cleanup callback; the parent no longer exists to authorize.
|
||||
*******************************************************************************/
|
||||
public void onParentsDeleted(String table, List<? extends Serializable> ids) throws QException
|
||||
{
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
for(QRecord link : CollaborationRecords.query(CollaborationTables.RECORD_TAG, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
|
||||
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList())))
|
||||
{
|
||||
CollaborationRecords.delete(CollaborationTables.RECORD_TAG, link.getValue("id"));
|
||||
recompute(link.getValue("tagId"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recount instead of incrementing a potentially stale denormalized value.
|
||||
*******************************************************************************/
|
||||
private void recompute(Serializable id) throws QException
|
||||
{
|
||||
CollaborationRecords.update(CollaborationTables.TAG, new QRecord().withValue("id", id).withValue("usageCount", links(id).size()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal references used by management actions after checking manage.
|
||||
*******************************************************************************/
|
||||
static List<QRecord> links(Serializable id) throws QException
|
||||
{
|
||||
return (CollaborationRecords.query(CollaborationTables.RECORD_TAG, new QQueryFilter().withCriteria("tagId", QCriteriaOperator.EQUALS, id)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical assignment list for an authorized parent.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> assignments(String table, String id) throws QException
|
||||
{
|
||||
return (CollaborationRecords.query(CollaborationTables.RECORD_TAG, CollaborationRecords.parent(table, id)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject missing and merged vocabulary ids.
|
||||
*******************************************************************************/
|
||||
static QRecord requireTag(Serializable id) throws QException
|
||||
{
|
||||
List<QRecord> rows = CollaborationRecords.query(CollaborationTables.TAG, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id)
|
||||
.withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Tag not found"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Table reads guard vocabulary calls without needing a particular record.
|
||||
*******************************************************************************/
|
||||
static void requireTable(String table) throws QException
|
||||
{
|
||||
if(!ParentRecordAccess.canReadTable(table))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical keys prevent aliases such as 01 from creating duplicate assignments.
|
||||
*******************************************************************************/
|
||||
private String parentId(String table, Serializable id) throws QException
|
||||
{
|
||||
return (ParentRecordAccess.requireVisibleParent(table, id).getValueString(QContext.getQInstance().getTable(table).getPrimaryKeyField()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Management is an explicit permission independent of collaboration.admin.
|
||||
*******************************************************************************/
|
||||
static boolean canManage()
|
||||
{
|
||||
return (QContext.getQSession().hasPermission(MANAGE));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared action and service permission check.
|
||||
*******************************************************************************/
|
||||
public static void requireManage() throws QPermissionDeniedException
|
||||
{
|
||||
if(!canManage())
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs " + MANAGE + "."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Public vocabulary projection.
|
||||
*******************************************************************************/
|
||||
private static TagEntry entry(QRecord row)
|
||||
{
|
||||
return (new TagEntry(row.getValueInteger("id"), row.getValueString("name"), row.getValueString("label"), row.getValueString("color"),
|
||||
row.getValueString("tableName"), row.getValueBoolean("approved"), row.getValueInteger("usageCount")));
|
||||
}
|
||||
}
|
||||
@@ -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.tasks;
|
||||
|
||||
|
||||
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 task reminder job. Runs with its caller's session, which must be a
|
||||
** scheduler system session or collaboration admin; never elevates a user.
|
||||
*******************************************************************************/
|
||||
public class SendTaskRemindersProcess implements BackendStep, CustomPermissionChecker
|
||||
{
|
||||
public static final String NAME = "collaborationSendTaskReminders";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always register the backend-only process; scheduling is opt-in.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata(CollaborationQBitConfig config)
|
||||
{
|
||||
QProcessMetaData process = new QProcessMetaData().withName(NAME).withLabel("Send task reminders").withIsHidden(true).withIcon(new QIcon("history"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName("collaboration").withCustomPermissionChecker(new QCodeReference(SendTaskRemindersProcess.class)))
|
||||
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(SendTaskRemindersProcess.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 reminders using fresh recipient permissions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
checkPermissionsThrowing(input, null);
|
||||
new TaskReminders().sendDue();
|
||||
}
|
||||
}
|
||||
@@ -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.tasks;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
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.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;
|
||||
|
||||
/*******************************************************************************
|
||||
** One scheduler per installation; receipts follow successful delivery only.
|
||||
*******************************************************************************/
|
||||
public class TaskReminders
|
||||
{
|
||||
private final Clock clock;
|
||||
|
||||
/*******************************************************************************
|
||||
** Production clock.
|
||||
*******************************************************************************/
|
||||
public TaskReminders()
|
||||
{
|
||||
this(Clock.systemUTC());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clock shared by due selection and successful-delivery receipts.
|
||||
*******************************************************************************/
|
||||
public TaskReminders(Clock clock)
|
||||
{
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No open transaction may defer receipts past the scheduler's serialization lock.
|
||||
*******************************************************************************/
|
||||
public void sendDue() throws QException
|
||||
{
|
||||
new SendTaskRemindersProcess().checkPermissionsThrowing(null, null);
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new QException("Send task reminders outside a transaction."));
|
||||
}
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
Instant now = clock.instant();
|
||||
for(String dueField : List.of("remindAt", "dueAt"))
|
||||
{
|
||||
String receipt = dueField.equals("remindAt") ? "remindedAt" : "overdueNotifiedAt";
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN")
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false).withCriteria("assigneeUserId", QCriteriaOperator.IS_NOT_BLANK)
|
||||
.withCriteria(receipt, QCriteriaOperator.IS_BLANK).withCriteria(dueField, QCriteriaOperator.IS_NOT_BLANK)
|
||||
.withCriteria(dueField, dueField.equals("dueAt") ? QCriteriaOperator.LESS_THAN : QCriteriaOperator.LESS_THAN_OR_EQUALS, now);
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.TASK, filter))
|
||||
{
|
||||
String table = row.getValueString("tableName");
|
||||
String id = row.getValueString("recordId");
|
||||
Notifications.send(new CollaborationNotification(row.getValueString("assigneeUserId"),
|
||||
(dueField.equals("remindAt") ? "Task reminder: " : "Task overdue: ") + row.getValueString("title"), row.getValueString("notes"),
|
||||
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)),
|
||||
() -> CollaborationRecords.update(CollaborationTables.TASK, new QRecord().withValue("id", row.getValue("id")).withValue(receipt, now)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.tasks;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.WorkSupport;
|
||||
import com.kingsrook.qbits.collaboration.activity.ActivityWriter;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowChangeBatcher;
|
||||
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.permissions.CollaborationPermissions;
|
||||
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.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Record tasks, authorized as the caller and timed through an injectable clock.
|
||||
*******************************************************************************/
|
||||
public class TasksService
|
||||
{
|
||||
private final Clock clock;
|
||||
private static final Comparator<QRecord> ORDER = Comparator.comparing((QRecord row) -> row.getValueInstant("dueAt"), Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(row -> row.getValueInteger("id"));
|
||||
|
||||
/*******************************************************************************
|
||||
** Production time source.
|
||||
*******************************************************************************/
|
||||
public TasksService()
|
||||
{
|
||||
this(Clock.systemUTC());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Deterministic reminder and completion times.
|
||||
*******************************************************************************/
|
||||
public TasksService(Clock clock)
|
||||
{
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An omitted assignee defaults to the creator.
|
||||
*******************************************************************************/
|
||||
public QRecord create(String table, Serializable recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt) throws QException
|
||||
{
|
||||
ParentRecordAccess.requireFeature(table, recordId, "task");
|
||||
String id = FollowService.canonicalId(table, recordId);
|
||||
String user = PersonalRecords.currentUserId();
|
||||
String assignee = assigneeUserId == null ? user : assigneeUserId;
|
||||
WorkSupport.text(title, 200, true);
|
||||
WorkSupport.requireReader(assignee, table, id);
|
||||
QRecord row = CollaborationRecords.insert(CollaborationTables.TASK, new QRecord().withValue("tableName", table).withValue("recordId", id)
|
||||
.withValue("title", title).withValue("notes", notes).withValue("assigneeUserId", assignee).withValue("dueAt", dueAt)
|
||||
.withValue("remindAt", reminder(dueAt, remindAt)).withValue("status", "OPEN").withValue("createdByUserId", user).withValue("parentDeleted", false));
|
||||
activity(row, "TASK_CREATED", "Task created");
|
||||
assigned(row);
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Full editable-field replacement; null dates clear them, null assignee unassigns.
|
||||
*******************************************************************************/
|
||||
public QRecord update(Integer taskId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt) throws QException
|
||||
{
|
||||
QRecord old = requireManager(taskId);
|
||||
WorkSupport.text(title, 200, true);
|
||||
WorkSupport.requireReader(assigneeUserId, old.getValueString("tableName"), old.getValueString("recordId"));
|
||||
QRecord change = new QRecord().withValue("id", taskId).withValue("title", title).withValue("notes", notes)
|
||||
.withValue("assigneeUserId", assigneeUserId).withValue("dueAt", dueAt).withValue("remindAt", reminder(dueAt, remindAt));
|
||||
if(!Objects.equals(old.getValueInstant("remindAt"), change.getValueInstant("remindAt")) || !Objects.equals(old.getValueString("assigneeUserId"), assigneeUserId))
|
||||
{
|
||||
change.withValue("remindedAt", null);
|
||||
}
|
||||
if(!Objects.equals(old.getValueInstant("dueAt"), dueAt) || !Objects.equals(old.getValueString("assigneeUserId"), assigneeUserId))
|
||||
{
|
||||
change.withValue("overdueNotifiedAt", null);
|
||||
}
|
||||
CollaborationRecords.update(CollaborationTables.TASK, change);
|
||||
QRecord row = WorkSupport.visible(CollaborationTables.TASK, taskId);
|
||||
if(!Objects.equals(old.getValueString("assigneeUserId"), assigneeUserId))
|
||||
{
|
||||
assigned(row);
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Creator, assignee and admins still need current parent and task permission.
|
||||
*******************************************************************************/
|
||||
public QRecord requireManager(Integer taskId) throws QException
|
||||
{
|
||||
QRecord row = WorkSupport.visible(CollaborationTables.TASK, taskId);
|
||||
String user = PersonalRecords.currentUserId();
|
||||
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "task");
|
||||
if(!user.equals(row.getValueString("createdByUserId")) && !user.equals(row.getValueString("assigneeUserId"))
|
||||
&& !QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Only the task creator, assignee or collaboration.admin can change this task."));
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Idempotent completion emits one activity for the transition.
|
||||
*******************************************************************************/
|
||||
public QRecord complete(Integer taskId) throws QException
|
||||
{
|
||||
return (transition(taskId, "DONE"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reopening clears completion fields, retaining delivery history.
|
||||
*******************************************************************************/
|
||||
public QRecord reopen(Integer taskId) throws QException
|
||||
{
|
||||
return (transition(taskId, "OPEN"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancellation stops scheduled notifications.
|
||||
*******************************************************************************/
|
||||
public QRecord cancel(Integer taskId) throws QException
|
||||
{
|
||||
return (transition(taskId, "CANCELLED"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize transitions within the installation, like existing follow services.
|
||||
*******************************************************************************/
|
||||
private QRecord transition(Integer taskId, String status) throws QException
|
||||
{
|
||||
synchronized(CollaborationRecords.config())
|
||||
{
|
||||
QRecord row = requireManager(taskId);
|
||||
if(!status.equals(row.getValueString("status")))
|
||||
{
|
||||
CollaborationRecords.update(CollaborationTables.TASK, new QRecord().withValue("id", taskId).withValue("status", status)
|
||||
.withValue("completedAt", status.equals("DONE") ? clock.instant() : null)
|
||||
.withValue("completedByUserId", status.equals("DONE") ? PersonalRecords.currentUserId() : null));
|
||||
if(status.equals("DONE"))
|
||||
{
|
||||
activity(row, "TASK_DONE", "Task completed");
|
||||
}
|
||||
}
|
||||
return (WorkSupport.visible(CollaborationTables.TASK, taskId));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parent readers can list open tasks, optionally including terminal tasks.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> list(String table, Serializable recordId, Boolean includeDone) throws QException
|
||||
{
|
||||
QQueryFilter filter = CollaborationRecords.parent(table, FollowService.canonicalId(table, recordId));
|
||||
if(!Boolean.TRUE.equals(includeDone))
|
||||
{
|
||||
filter.withCriteria("status", QCriteriaOperator.EQUALS, "OPEN");
|
||||
}
|
||||
return (CollaborationRecords.query(CollaborationTables.TASK, filter).stream().sorted(ORDER).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Due-date keyset paging, null dates last, with stable ID tie-breaking.
|
||||
*******************************************************************************/
|
||||
public Page mine(String status, Integer limit, String cursor) throws QException
|
||||
{
|
||||
int size = limit == null ? 20 : limit;
|
||||
if(size < 1 || size > 100)
|
||||
{
|
||||
throw (new QException("Limit must be between 1 and 100."));
|
||||
}
|
||||
List<QRecord> rows = mineRows(status);
|
||||
if(cursor != null)
|
||||
{
|
||||
QRecord after = decode(cursor);
|
||||
rows = rows.stream().filter(row -> ORDER.compare(row, after) > 0).toList();
|
||||
}
|
||||
List<QRecord> page = rows.stream().limit(size).toList();
|
||||
return (new Page(page, rows.size() > size ? encode(page.getLast()) : null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Also supplies exact visible counts for My work; no aggregate lock leakage.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> mineRows(String status) throws QException
|
||||
{
|
||||
String selected = status == null ? "OPEN" : status;
|
||||
if(!List.of("OPEN", "DONE", "CANCELLED").contains(selected))
|
||||
{
|
||||
throw (new QException("Unknown task status."));
|
||||
}
|
||||
List<QRecord> visible = new ArrayList<>();
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.TASK, new QQueryFilter()
|
||||
.withCriteria("assigneeUserId", QCriteriaOperator.EQUALS, PersonalRecords.currentUserId()).withCriteria("status", QCriteriaOperator.EQUALS, selected)
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false)))
|
||||
{
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
|
||||
visible.add(row);
|
||||
}
|
||||
catch(QNotFoundException expected)
|
||||
{
|
||||
// Removed access removes the task from personal results.
|
||||
}
|
||||
}
|
||||
return (visible.stream().sorted(ORDER).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The default never schedules a reminder in the past for a future due date.
|
||||
*******************************************************************************/
|
||||
private Instant reminder(Instant due, Instant supplied)
|
||||
{
|
||||
return (supplied != null ? supplied : due == null ? null : due.minusSeconds(86400).isAfter(clock.instant()) ? due.minusSeconds(86400) : due);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assignees become followers; self-assignment avoids an immediate notification.
|
||||
*******************************************************************************/
|
||||
private void assigned(QRecord row) throws QException
|
||||
{
|
||||
String user = row.getValueString("assigneeUserId");
|
||||
if(user != null)
|
||||
{
|
||||
String table = row.getValueString("tableName");
|
||||
String id = row.getValueString("recordId");
|
||||
new FollowService().ensureFollower(user, table, id, "ASSIGNED");
|
||||
if(!user.equals(PersonalRecords.currentUserId()))
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(user, "Task assigned: " + row.getValueString("title"), row.getValueString("notes"),
|
||||
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Task events also enter the existing notify-on-change follower digest.
|
||||
*******************************************************************************/
|
||||
private void activity(QRecord row, String type, String summary) throws QException
|
||||
{
|
||||
String table = row.getValueString("tableName");
|
||||
String id = row.getValueString("recordId");
|
||||
ActivityWriter.write(table, id, type, summary, CollaborationTables.TASK, row.getValueString("id"));
|
||||
new FollowChangeBatcher(clock).recordChange(table, id, PersonalRecords.currentUserId(), Set.of(summary));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned cursor includes only the sort tuple.
|
||||
*******************************************************************************/
|
||||
private String encode(QRecord row)
|
||||
{
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString(("1\n" + row.getValueInstant("dueAt") + "\n" + row.getValueInteger("id")).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject malformed cursor input before interpreting the sort tuple.
|
||||
*******************************************************************************/
|
||||
private QRecord decode(String cursor) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
String[] parts = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8).split("\n");
|
||||
if(parts.length != 3 || !parts[0].equals("1"))
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid cursor"));
|
||||
}
|
||||
return (new QRecord().withValue("dueAt", parts[1].equals("null") ? null : Instant.parse(parts[1])).withValue("id", Integer.valueOf(parts[2])));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
throw (new QException("Invalid cursor", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Service page consumed by the future endpoint adapter.
|
||||
*******************************************************************************/
|
||||
public record Page(List<QRecord> tasks, String nextCursor)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
@@ -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<TimelineDetail> details, String refTable, String refId)
|
||||
{
|
||||
}
|
||||
@@ -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<TimelineItem> items, String nextCursor)
|
||||
{
|
||||
}
|
||||
@@ -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<String> 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<TimelineItem> 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<TimelineItem> 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<TimelineItem> 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<QRecord> 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<QRecord> 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<Long, List<TimelineDetail>> 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<Integer, String> 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<TimelineItem> 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<QRecord> 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()));
|
||||
}
|
||||
}
|
||||
@@ -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.TAG, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
|
||||
assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET);
|
||||
assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull();
|
||||
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
|
||||
@@ -177,9 +177,9 @@ class CollaborationTest
|
||||
QInstance headless = new QInstance();
|
||||
headless.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
new CollaborationQBitProducer().withQBitConfig(config.withAdminApp(false)).produce(headless);
|
||||
assertThat(headless.getTables()).hasSize(5);
|
||||
assertThat(headless.getTables()).hasSize(17);
|
||||
assertThat(headless.getApps()).isEmpty();
|
||||
assertThat(headless.getWidgets()).isEmpty();
|
||||
assertThat(headless.getWidgets()).hasSize(4);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY 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<String, QSession> 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<QSession> 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<ProcessSummaryLine> lines = (List<ProcessSummaryLine>) 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<ProcessSummaryLine>) 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")).withTagActionTables(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(2);
|
||||
assertThat(installed.getProcess("collaborationAddTags_document").getLabel()).isEqualTo("Add tags");
|
||||
assertThat(installed.getProcess("collaborationRemoveTags_document").getLabel()).isEqualTo("Remove tags");
|
||||
assertThat(installed.getProcess(SendFollowDigestsProcess.NAME).getSchedule().getRepeatSeconds()).isEqualTo(60);
|
||||
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</h3><p>1", "Open threads</h3><p>1", "Followers</h3><p>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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
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.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentsService;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.mywork.MyWorkService;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier;
|
||||
import com.kingsrook.qbits.collaboration.notify.RecordingNotifier;
|
||||
import com.kingsrook.qbits.collaboration.security.UserSessionSupplier;
|
||||
import com.kingsrook.qbits.collaboration.sharing.AccessRequestService;
|
||||
import com.kingsrook.qbits.collaboration.sharing.PermissionHolderDirectory;
|
||||
import com.kingsrook.qbits.collaboration.sharing.SharingService;
|
||||
import com.kingsrook.qbits.collaboration.tasks.SendTaskRemindersProcess;
|
||||
import com.kingsrook.qbits.collaboration.tasks.TaskReminders;
|
||||
import com.kingsrook.qbits.collaboration.tasks.TasksService;
|
||||
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.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Service and streamed-process acceptance with real parent locks and notifications.
|
||||
*******************************************************************************/
|
||||
public class SharingWorkTest extends PersonalServiceFixture
|
||||
{
|
||||
private static RecordingNotifier recorder;
|
||||
private static final Map<String, QSession> sessions = new HashMap<>();
|
||||
private static final Instant NOW = Instant.parse("2026-10-13T12:00:00Z");
|
||||
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
private final SharingService sharing = new SharingService();
|
||||
private final AccessRequestService access = new AccessRequestService();
|
||||
private final TasksService tasks = new TasksService(CLOCK);
|
||||
private final AssignmentService assignments = new AssignmentService();
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void configure() throws Exception
|
||||
{
|
||||
recorder = new RecordingNotifier();
|
||||
sessions.clear();
|
||||
sessions.put("alice", session("alice", "a"));
|
||||
sessions.put("bob", session("bob", "a"));
|
||||
sessions.put("eve", session("eve", "b"));
|
||||
config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionSupplier(new QCodeReference(TestSessions.class));
|
||||
QContext.getQSession().getUser().setFullName("Alice");
|
||||
CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "eve").withValue("displayName", "Sam"));
|
||||
WorkProcesses.assignments("document").forEach(instance::addProcess);
|
||||
new QInstanceValidator().validate(instance);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Test Notifier.
|
||||
*******************************************************************************/
|
||||
public static class TestNotifier implements CollaborationNotifier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Use the configured test boundary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(CollaborationNotification notification)
|
||||
{
|
||||
recorder.notify(notification);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Test Sessions.
|
||||
*******************************************************************************/
|
||||
public static class TestSessions implements UserSessionSupplier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Use the configured test boundary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<QSession> sessionFor(String user)
|
||||
{
|
||||
return (Optional.ofNullable(sessions.get(user)));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Directory.
|
||||
*******************************************************************************/
|
||||
public static class Directory implements PermissionHolderDirectory
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Use the configured test boundary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Set<String> userIdsWithPermission(String permission)
|
||||
{
|
||||
return (permission.equals("collaboration.admin") ? Set.of("admin") : Set.of("approver", "admin"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Session.
|
||||
*******************************************************************************/
|
||||
private static QSession session(String user, String tenant)
|
||||
{
|
||||
return (new QSession().withUser(new QUser().withIdReference(user)).withPermissions("document.read", "document.task").withSecurityKeyValue("tenant", tenant));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Links Preview And Recipient Safe Shares.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void linksPreviewAndRecipientSafeShares() throws Exception
|
||||
{
|
||||
assertThat(sharing.link("document", "01")).isEqualTo(new SharingService.Link("/app/document/1", "First document"));
|
||||
config.withFrontendBaseUrl("https://example.test/");
|
||||
assertThat(sharing.link("document", 1).url()).isEqualTo("https://example.test/app/document/1");
|
||||
assertThat(TargetResolver.recordHref("a b", "a/b?#")).isEqualTo("https://example.test/app/a%20b/a%2Fb%3F%23");
|
||||
assertThat(sharing.preview("document", 1, List.of("bob", "eve", "missing")))
|
||||
.containsExactly(new SharingService.Recipient("bob", "bob", true), new SharingService.Recipient("eve", "Sam", false), new SharingService.Recipient("missing", "missing", null));
|
||||
List<QRecord> shares = sharing.share("document", 1, List.of("bob", "eve", "missing"), "First document confidential contents");
|
||||
assertThat(shares).hasSize(3);
|
||||
assertThat(recorder.notifications()).hasSize(3);
|
||||
assertThat(recorder.notifications().getFirst().title()).contains("Alice shared First document with you");
|
||||
for(CollaborationNotification notification : recorder.notifications().subList(1, 3))
|
||||
{
|
||||
assertThat(notification.title() + notification.body()).doesNotContain("First document", "confidential contents");
|
||||
assertThat(notification.title()).contains("Documents");
|
||||
assertThat(notification.link()).startsWith("/app/collaboration_access_request/create?tableName=document&recordId=1&shareId=");
|
||||
assertThat(notification.sourceTableName()).isNull();
|
||||
}
|
||||
assertThat(new FollowService().followers("document", 1)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.userId()).isEqualTo("bob");
|
||||
assertThat(row.reason()).isEqualTo("SHARED");
|
||||
});
|
||||
assertThat(rows(CollaborationTables.ACTIVITY).stream().filter(row -> "SHARED".equals(row.getValueString("type")))).hasSize(3);
|
||||
assertThatThrownBy(() -> sharing.markOpened(shares.getFirst().getValueInteger("id"))).hasMessage("Not found");
|
||||
login("bob");
|
||||
sharing.markOpened(shares.getFirst().getValueInteger("id"));
|
||||
Instant opened = rows(CollaborationTables.SHARE).getFirst().getValueInstant("openedAt");
|
||||
sharing.markOpened(shares.getFirst().getValueInteger("id"));
|
||||
assertThat(rows(CollaborationTables.SHARE).getFirst().getValueInstant("openedAt")).isEqualTo(opened);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sharing Validation And After Commit Checks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sharingValidationAndAfterCommitChecks() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> sharing.preview("document", 1, List.of())).hasMessageContaining("1 to 25");
|
||||
assertThatThrownBy(() -> sharing.preview("document", 1, java.util.Collections.nCopies(26, "bob"))).hasMessageContaining("1 to 25");
|
||||
assertThatThrownBy(() -> sharing.share("document", 1, List.of("bob"), "x".repeat(2001))).hasMessageContaining("2000");
|
||||
assertThatThrownBy(() -> sharing.link("document", 3)).hasMessage("Not found");
|
||||
QBackendTransaction tx = new QBackendTransaction();
|
||||
QContext.setTransaction(tx);
|
||||
sharing.share("document", 1, List.of("bob", "eve"), "First document");
|
||||
assertThat(recorder.notifications()).isEmpty();
|
||||
sessions.put("bob", session("bob", "b"));
|
||||
tx.commit();
|
||||
QContext.clearTransaction();
|
||||
assertThat(recorder.notifications()).singleElement().satisfies(n -> assertThat(n.recipientUserId()).isEqualTo("eve"));
|
||||
QContext.getQSession().setPermissions(Set.of("document.read"));
|
||||
assertThatThrownBy(() -> sharing.preview("document", 1, List.of("bob"))).hasMessageContaining("share");
|
||||
QContext.getQSession().setPermissions(Set.of("document.read", "document.share"));
|
||||
assertThat(sharing.preview("document", 1, List.of("alice"))).singleElement().satisfies(r -> assertThat(r.canRead()).isTrue());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Requests Do Not Reveal Existence And Remain Owner Locked.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void requestsDoNotRevealExistenceAndRemainOwnerLocked() throws Exception
|
||||
{
|
||||
QContext.getQSession().setPermissions(Set.of());
|
||||
QRecord existing = access.request("document", 3, "Please", null);
|
||||
QRecord missing = access.request("document", 999, "Please", null);
|
||||
assertThat(existing.getValues().keySet()).isEqualTo(missing.getValues().keySet());
|
||||
for(String field : existing.getValues().keySet())
|
||||
{
|
||||
if(!List.of("id", "recordId", "createDate", "modifyDate").contains(field))
|
||||
{
|
||||
assertThat(existing.getValue(field)).isEqualTo(missing.getValue(field));
|
||||
}
|
||||
}
|
||||
assertThat(existing.getValues()).doesNotContainKeys("label", "recordLabel", "title");
|
||||
assertThat(access.request("document", "03", "Changed", null).getValue("id")).isEqualTo(existing.getValue("id"));
|
||||
assertThat(visibleRequests()).hasSize(2);
|
||||
assertThat(visibleRequests().getFirst().getDisplayValue("tableName")).isEqualTo("Documents");
|
||||
login("bob");
|
||||
assertThat(visibleRequests()).isEmpty();
|
||||
assertThatThrownBy(() -> access.withdraw(existing.getValueInteger("id"))).hasMessage("Not found");
|
||||
admin();
|
||||
assertThat(visibleRequests()).hasSize(2);
|
||||
login("alice");
|
||||
access.withdraw(existing.getValueInteger("id"));
|
||||
assertThatThrownBy(() -> access.withdraw(existing.getValueInteger("id"))).hasMessageContaining("no longer open");
|
||||
assertThat(access.request("document", 3, null, null).getValue("id")).isNotEqualTo(existing.getValue("id"));
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(CollaborationTables.ACCESS_REQUEST).withRecords(List.of(new QRecord()))))
|
||||
.hasMessageContaining("service actions");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Requests Resolve Directory And Owners And Record Outcomes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void requestsResolveDirectoryAndOwnersAndRecordOutcomes() throws Exception
|
||||
{
|
||||
config.withPermissionHolderDirectory(new QCodeReference(Directory.class));
|
||||
CollaborationRecords.insert(CollaborationTables.FEATURE_SETTING, new QRecord().withValue("scopeType", "TABLE").withValue("scopeName", "document")
|
||||
.withValue("feature", "SHARING").withValue("enabled", true).withValue("settings", "{\"accessApproverPermission\":\"document.approve\"}"));
|
||||
assignments.assign("document", 1, "bob", null);
|
||||
QRecord request = access.request("document", 1, "First document", null);
|
||||
assertThat(recorder.notifications()).extracting(CollaborationNotification::recipientUserId).containsExactlyInAnyOrder("admin", "approver", "bob");
|
||||
assertThat(recorder.notifications()).allSatisfy(n -> assertThat(n.title() + n.body()).doesNotContain("First document"));
|
||||
assertThatThrownBy(() -> access.handle(request.getValueInteger("id"), "GRANTED", null)).hasMessageContaining("approver permission");
|
||||
login("approver", "document.approve");
|
||||
assertThat(visibleRequests()).isEmpty();
|
||||
access.handle(request.getValueInteger("id"), "GRANTED", "Changed in identity system");
|
||||
assertThat(rows(CollaborationTables.ACCESS_REQUEST).getFirst().getValueString("status")).isEqualTo("GRANTED");
|
||||
assertThat(recorder.notifications().getLast().recipientUserId()).isEqualTo("alice");
|
||||
assertThat(recorder.notifications().getLast().body()).doesNotContain("Changed in identity system");
|
||||
assertThat(QContext.getQSession().hasPermission("document.approve")).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Share References Cannot Be Forged.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void shareReferencesCannotBeForged() throws Exception
|
||||
{
|
||||
Integer share = sharing.share("document", 1, List.of("eve"), "secret").getFirst().getValueInteger("id");
|
||||
assertThatThrownBy(() -> access.request("document", 1, null, share)).hasMessage("Not found");
|
||||
login("eve");
|
||||
assertThatThrownBy(() -> access.request("document", 2, null, share)).hasMessage("Not found");
|
||||
assertThat(access.request("document", 1, null, share).getValueInteger("shareId")).isEqualTo(share);
|
||||
sharing.markOpened(share);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Tasks Refuse Unknown Assignees And Use Spec Reminder Defaults.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tasksRefuseUnknownAssigneesAndUseSpecReminderDefaults() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> tasks.create("document", 1, "Work", null, "eve", null, null)).hasMessage("Sam can't open this record.");
|
||||
assertThatThrownBy(() -> tasks.create("document", 1, "Work", null, "missing", null, null)).hasMessageContaining("can't open");
|
||||
assertThatThrownBy(() -> tasks.create("document", 1, "x".repeat(201), null, null, null, null)).hasMessageContaining("200");
|
||||
QRecord future = tasks.create("document", 1, "Future", "Notes", "bob", NOW.plusSeconds(172800), null);
|
||||
assertThat(future.getValueInstant("remindAt")).isEqualTo(NOW.plusSeconds(86400));
|
||||
assertThat(recorder.notifications()).singleElement().satisfies(n -> assertThat(n.title()).contains("Future"));
|
||||
assertThat(new FollowService().followers("document", 1)).singleElement().satisfies(f -> assertThat(f.reason()).isEqualTo("ASSIGNED"));
|
||||
QRecord soon = tasks.create("document", 1, "Soon", null, null, NOW.plusSeconds(3600), null);
|
||||
assertThat(soon.getValueInstant("remindAt")).isEqualTo(soon.getValueInstant("dueAt"));
|
||||
assertThat(soon.getValueString("assigneeUserId")).isEqualTo("alice");
|
||||
config.withUserSessionSupplier(null);
|
||||
assertThat(tasks.create("document", 1, "Self", null, null, null, null).getValueInstant("remindAt")).isNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Task Transitions Updates And Mine Ordering.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void taskTransitionsUpdatesAndMineOrdering() throws Exception
|
||||
{
|
||||
QRecord undated = tasks.create("document", 1, "Undated", null, null, null, null);
|
||||
QRecord later = tasks.create("document", 1, "Later", null, null, NOW.plusSeconds(200), null);
|
||||
QRecord first = tasks.create("document", 2, "First", null, null, NOW.plusSeconds(100), null);
|
||||
QRecord tie = tasks.create("document", 2, "Tie", null, null, NOW.plusSeconds(100), null);
|
||||
TasksService.Page page = tasks.mine(null, 2, null);
|
||||
assertThat(page.tasks()).extracting(r -> r.getValueString("title")).containsExactly("First", "Tie");
|
||||
assertThat(tasks.mine("OPEN", 2, page.nextCursor()).tasks()).extracting(r -> r.getValueString("title")).containsExactly("Later", "Undated");
|
||||
assertThatThrownBy(() -> tasks.mine(null, 0, null)).hasMessageContaining("Limit");
|
||||
assertThatThrownBy(() -> tasks.mine(null, 20, "bad")).hasMessageContaining("cursor");
|
||||
assertThatThrownBy(() -> tasks.mine("NOPE", 20, null)).hasMessageContaining("status");
|
||||
tasks.complete(first.getValueInteger("id"));
|
||||
tasks.complete(first.getValueInteger("id"));
|
||||
assertThat(tasks.mine("DONE", 10, null).tasks()).singleElement().satisfies(r -> assertThat(r.getValueInstant("completedAt")).isEqualTo(NOW));
|
||||
assertThat(rows(CollaborationTables.ACTIVITY).stream().filter(r -> "TASK_DONE".equals(r.getValueString("type")))).hasSize(1);
|
||||
tasks.reopen(first.getValueInteger("id"));
|
||||
assertThat(tasks.list("document", 2, false)).allSatisfy(r -> assertThat(r.getValueInstant("completedAt")).isNull());
|
||||
tasks.cancel(tie.getValueInteger("id"));
|
||||
assertThat(tasks.list("document", 2, false)).hasSize(1);
|
||||
assertThat(tasks.list("document", 2, true)).hasSize(2);
|
||||
tasks.update(later.getValueInteger("id"), "Updated", "notes", "bob", NOW, NOW.minusSeconds(10));
|
||||
login("bob");
|
||||
tasks.complete(later.getValueInteger("id"));
|
||||
assertThatThrownBy(() -> tasks.complete(undated.getValueInteger("id"))).hasMessageContaining("creator, assignee");
|
||||
admin();
|
||||
tasks.complete(undated.getValueInteger("id"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reminders And Overdue Are Delivered Once With Fresh Access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void remindersAndOverdueAreDeliveredOnceWithFreshAccess() throws Exception
|
||||
{
|
||||
QRecord task = tasks.create("document", 1, "Scheduled", null, "bob", NOW.plusSeconds(100), NOW);
|
||||
recorder = new RecordingNotifier();
|
||||
assertThatThrownBy(() -> new TaskReminders(CLOCK).sendDue()).hasMessageContaining("admin");
|
||||
admin();
|
||||
TaskReminders reminders = new TaskReminders(CLOCK);
|
||||
reminders.sendDue();
|
||||
reminders.sendDue();
|
||||
assertThat(recorder.notifications()).hasSize(1);
|
||||
assertThat(WorkSupport.row(CollaborationTables.TASK, task.getValueInteger("id")).getValueInstant("remindedAt")).isEqualTo(NOW);
|
||||
new TaskReminders(Clock.fixed(NOW.plusSeconds(101), ZoneOffset.UTC)).sendDue();
|
||||
new TaskReminders(Clock.fixed(NOW.plusSeconds(102), ZoneOffset.UTC)).sendDue();
|
||||
assertThat(recorder.notifications()).hasSize(2);
|
||||
assertThat(recorder.notifications().getLast().title()).contains("overdue");
|
||||
tasks.update(task.getValueInteger("id"), "Changed", null, "bob", NOW.minusSeconds(1), NOW.minusSeconds(1));
|
||||
sessions.put("bob", session("bob", "b"));
|
||||
reminders.sendDue();
|
||||
assertThat(recorder.notifications()).hasSize(2);
|
||||
assertThat(WorkSupport.row(CollaborationTables.TASK, task.getValueInteger("id")).getValueInstant("remindedAt")).isNull();
|
||||
sessions.put("bob", session("bob", "a"));
|
||||
reminders.sendDue();
|
||||
assertThat(recorder.notifications()).hasSize(4);
|
||||
tasks.cancel(task.getValueInteger("id"));
|
||||
reminders.sendDue();
|
||||
assertThat(recorder.notifications()).hasSize(4);
|
||||
QContext.setTransaction(new QBackendTransaction());
|
||||
assertThatThrownBy(reminders::sendDue).hasMessageContaining("outside a transaction");
|
||||
QContext.clearTransaction();
|
||||
new TaskReminders().sendDue();
|
||||
new SendTaskRemindersProcess().run(null, null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assignments Upsert And My Records Respect Parent Locks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void assignmentsUpsertAndMyRecordsRespectParentLocks() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> assignments.assign("document", 1, "eve", null)).hasMessageContaining("can't open");
|
||||
QRecord first = assignments.assign("document", 1, "alice", "bob");
|
||||
QRecord again = assignments.assign("document", "01", "alice", "bob");
|
||||
assertThat(again.getValue("id")).isEqualTo(first.getValue("id"));
|
||||
assertThat(recorder.notifications()).hasSize(1);
|
||||
assignments.assign("document", 2, null, "alice");
|
||||
assertThat(assignments.myRecords(null, 1).get("document").count()).isEqualTo(2);
|
||||
assertThat(assignments.myRecords("document", 1).get("document").records()).hasSize(1);
|
||||
assertThatThrownBy(() -> assignments.myRecords(null, 101)).hasMessageContaining("Limit");
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_ASSIGNMENT))).hasMessageContaining("collaboration");
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("b")));
|
||||
assertThat(assignments.myRecords(null, 10)).isEmpty();
|
||||
assertThat(instance.getTable(CollaborationTables.RECORD_ASSIGNMENT).getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** My Work Summarizes Only Visible Work And Read Mentions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void myWorkSummarizesOnlyVisibleWorkAndReadMentions() throws Exception
|
||||
{
|
||||
for(int i = 0; i < 12; i++)
|
||||
{
|
||||
tasks.create("document", 1, "Task " + i, null, "bob", null, null);
|
||||
}
|
||||
assignments.assign("document", 1, "alice", "bob");
|
||||
new CommentsService().post("document", 1, "@[Bob](user:bob)", null, null);
|
||||
login("bob");
|
||||
MyWorkService service = new MyWorkService();
|
||||
MyWorkService.Summary summary = service.summary();
|
||||
assertThat(summary.openTaskCount()).isEqualTo(12);
|
||||
assertThat(summary.tasks()).hasSize(10);
|
||||
assertThat(summary.assignedRecordsByTable()).containsEntry("document", 1L);
|
||||
assertThat(summary.unreadMentionCount()).isEqualTo(1);
|
||||
service.markMentionsRead(List.of(-1));
|
||||
assertThat(service.summary().unreadMentionCount()).isEqualTo(1);
|
||||
service.markMentionsRead(null);
|
||||
assertThat(service.summary().unreadMentionCount()).isZero();
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("b")));
|
||||
assertThat(service.summary().openTaskCount()).isZero();
|
||||
assertThat(service.summary().assignedRecordsByTable()).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Actual Streamed Actions Have Review And Result.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void actualStreamedActionsHaveReviewAndResult() throws Exception
|
||||
{
|
||||
login("alice", "collaboration.view");
|
||||
QRecord task = tasks.create("document", 1, "Action", null, null, null, null);
|
||||
RunProcessOutput review = review("collaborationDone_collaboration_task", task.getValueString("id"), Map.of());
|
||||
assertThat(lines(review, false)).anySatisfy(line -> assertThat(line.getPluralFutureMessage()).contains("will be processed"));
|
||||
assertThat(lines(finish(review), true)).anySatisfy(line -> assertThat(line.getPluralPastMessage()).contains("processed"));
|
||||
assertThat(tasks.list("document", 1, false)).isEmpty();
|
||||
finish(review("collaborationReopen_collaboration_task", task.getValueString("id"), Map.of()));
|
||||
assertThat(tasks.list("document", 1, false)).hasSize(1);
|
||||
finish(review("collaborationAssign_document", "1,2", Map.of("assigneeUserId", "bob")));
|
||||
finish(review("collaborationOwn_document", "1,2", Map.of()));
|
||||
assertThat(assignments.myRecords(null, 10).get("document").count()).isEqualTo(2);
|
||||
assertThat(assignments.get("document", 1).orElseThrow().getValueString("assigneeUserId")).isEqualTo("bob");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Access Actions Work For Admin And Configured Approver.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void accessActionsWorkForAdminAndConfiguredApprover() throws Exception
|
||||
{
|
||||
QRecord granted = access.request("document", 999, null, null);
|
||||
QRecord declined = access.request("document", 998, null, null);
|
||||
admin();
|
||||
RunProcessOutput grant = review("collaborationGrant_collaboration_access_request", granted.getValueString("id"), Map.of("note", "Identity system updated"));
|
||||
assertThat(grant.getValues().get(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE)).isEqualTo("Record the outcome; grant access in your identity system first.");
|
||||
finish(grant);
|
||||
CollaborationRecords.insert(CollaborationTables.FEATURE_SETTING, new QRecord().withValue("scopeType", "TABLE").withValue("scopeName", "document")
|
||||
.withValue("feature", "SHARING").withValue("enabled", true).withValue("settings", "{\"accessApproverPermission\":\"document.approve\"}"));
|
||||
login("approver", "document.approve");
|
||||
finish(review("collaborationDecline_collaboration_access_request", declined.getValueString("id"), Map.of()));
|
||||
assertThat(rows(CollaborationTables.ACCESS_REQUEST)).extracting(r -> r.getValueString("status")).containsExactly("GRANTED", "DECLINED");
|
||||
assertThat(recorder.notifications()).extracting(CollaborationNotification::title).contains("Access request granted", "Access request declined");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup Preserves Requests And Schema Includes Work Indexes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cleanupPreservesRequestsAndSchemaIncludesWorkIndexes() throws Exception
|
||||
{
|
||||
tasks.create("document", 1, "Deleted", null, null, null, null);
|
||||
assignments.assign("document", 1, "alice", null);
|
||||
sharing.share("document", 1, List.of("bob"), null);
|
||||
access.request("document", 1, null, null);
|
||||
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
|
||||
assertThat(rows(CollaborationTables.TASK)).isEmpty();
|
||||
assertThat(rows(CollaborationTables.RECORD_ASSIGNMENT)).isEmpty();
|
||||
assertThat(rows(CollaborationTables.SHARE)).isEmpty();
|
||||
assertThat(rows(CollaborationTables.ACCESS_REQUEST)).hasSize(1);
|
||||
new WorkCleanup().onParentsDeleted("document", List.of(1));
|
||||
new WorkCleanup().onParentsDeleted("document", List.of());
|
||||
String sql = CollaborationSchema.postgresScript(config);
|
||||
assertThat(sql).contains("collaboration_share", "collaboration_access_request", "collaboration_task", "collaboration_record_assignment", "overdue_notified_at",
|
||||
"read_at", "WHERE status = 'OPEN'", "assignee_user_id, status, due_at", "requester_user_id", "owner_user_id");
|
||||
for(String table : List.of(CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.TASK, CollaborationTables.RECORD_ASSIGNMENT))
|
||||
{
|
||||
assertThat(instance.getTable(table).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
|
||||
}
|
||||
assertThat(instance.getTable(CollaborationTables.TASK).getSearchFields()).containsExactly("title");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Home Shows Real Counts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeShowsRealCounts() throws Exception
|
||||
{
|
||||
tasks.create("document", 1, "Overdue", null, null, NOW.minusSeconds(60), null);
|
||||
sharing.share("document", 1, List.of("bob"), null);
|
||||
access.request("document", 999, null, null);
|
||||
admin();
|
||||
String html = CollaborationHome.render(Instant.now().plusSeconds(5));
|
||||
assertThat(html).contains("Open access requests</h3><p>1", "Open tasks</h3><p>1", "Overdue tasks</h3><p>1", "Shares this week</h3><p>1");
|
||||
assertThat(SendTaskRemindersProcess.metadata(config).getIsHidden()).isTrue();
|
||||
assertThat(SendTaskRemindersProcess.metadata(config).getSchedule()).isNull();
|
||||
assertThat(SendTaskRemindersProcess.metadata(config.withSchedulerName("clock")).getSchedule().getRepeatSeconds()).isEqualTo(60);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Undated Tasks And No Op Delivery Never Receive Receipts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void undatedTasksAndNoOpDeliveryNeverReceiveReceipts() throws Exception
|
||||
{
|
||||
tasks.create("document", 1, "No deadline", null, "bob", null, null);
|
||||
QRecord due = tasks.create("document", 2, "Due", null, "bob", NOW.minusSeconds(1), NOW);
|
||||
recorder = new RecordingNotifier();
|
||||
admin();
|
||||
config.withNotifier(new QCodeReference(com.kingsrook.qbits.collaboration.notify.NoOpNotifier.class));
|
||||
new TaskReminders(CLOCK).sendDue();
|
||||
assertThat(WorkSupport.row(CollaborationTables.TASK, due.getValueInteger("id")).getValueInstant("remindedAt")).isNull();
|
||||
assertThat(WorkSupport.row(CollaborationTables.TASK, due.getValueInteger("id")).getValueInstant("overdueNotifiedAt")).isNull();
|
||||
config.withNotifier(new QCodeReference(TestNotifier.class));
|
||||
new TaskReminders(CLOCK).sendDue();
|
||||
assertThat(recorder.notifications()).hasSize(2).allSatisfy(n -> assertThat(n.title()).doesNotContain("No deadline"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cleanup Waits For Commit And Request Get Remains Owner Locked.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cleanupWaitsForCommitAndRequestGetRemainsOwnerLocked() throws Exception
|
||||
{
|
||||
tasks.create("document", 1, "Cleanup", null, null, null, null);
|
||||
QRecord request = access.request("document", 1, null, null);
|
||||
QBackendTransaction tx = new QBackendTransaction();
|
||||
QContext.setTransaction(tx);
|
||||
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)).withTransaction(tx));
|
||||
assertThat(rows(CollaborationTables.TASK)).hasSize(1);
|
||||
tx.commit();
|
||||
QContext.clearTransaction();
|
||||
assertThat(rows(CollaborationTables.TASK)).isEmpty();
|
||||
login("bob");
|
||||
assertThat(new GetAction().execute(new GetInput(CollaborationTables.ACCESS_REQUEST).withPrimaryKey(request.getValue("id"))).getRecord()).isNull();
|
||||
admin();
|
||||
assertThat(new GetAction().execute(new GetInput(CollaborationTables.ACCESS_REQUEST).withPrimaryKey(request.getValue("id"))).getRecord()).isNotNull();
|
||||
login("alice");
|
||||
assertThat(new GetAction().execute(new GetInput(CollaborationTables.ACCESS_REQUEST).withPrimaryKey(request.getValue("id"))).getRecord()).isNotNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows(String table) throws Exception
|
||||
{
|
||||
return (CollaborationRecords.query(table, new QQueryFilter()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Visible Requests.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> visibleRequests() throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(CollaborationTables.ACCESS_REQUEST).withShouldGenerateDisplayValues(true)).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review.
|
||||
*******************************************************************************/
|
||||
private RunProcessOutput review(String name, String records, Map<String, Serializable> values) throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(name);
|
||||
input.addValue("testProcessName", name);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
input.addValue("recordsParam", "recordIds");
|
||||
input.addValue("recordIds", records);
|
||||
values.forEach(input::addValue);
|
||||
if(instance.getProcess(name).getStep("details") != null)
|
||||
{
|
||||
RunProcessOutput picker = new RunProcessAction().execute(input);
|
||||
assertThat(picker.getException()).isEmpty();
|
||||
input.setProcessUUID(picker.getProcessUUID());
|
||||
input.setStartAfterStep("details");
|
||||
}
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Finish.
|
||||
*******************************************************************************/
|
||||
private RunProcessOutput finish(RunProcessOutput review) throws Exception
|
||||
{
|
||||
RunProcessInput go = new RunProcessInput();
|
||||
go.setProcessName((String) review.getValues().get("testProcessName"));
|
||||
go.setProcessUUID(review.getProcessUUID());
|
||||
go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW);
|
||||
go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
RunProcessOutput result = new RunProcessAction().execute(go);
|
||||
assertThat(result.getException()).isEmpty();
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the standard streamed process summary.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ProcessSummaryLine> lines(RunProcessOutput output, boolean result)
|
||||
{
|
||||
return ((List<ProcessSummaryLine>) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentCleanup;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagEntry;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagFilterTranslator;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagProcesses;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagsService;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
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.context.QContext;
|
||||
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.count.CountInput;
|
||||
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.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
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.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Real QQQ actions exercise tag permissions, persistence, processes and metadata.
|
||||
*******************************************************************************/
|
||||
public class TagsServiceTest extends PersonalServiceFixture
|
||||
{
|
||||
private final TagsService tags = new TagsService();
|
||||
private final TagFilterTranslator filters = new TagFilterTranslator();
|
||||
|
||||
/*******************************************************************************
|
||||
** Natural keys are locale-independent and validate after collapsing whitespace.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void normalizationAndFreeCreate() throws Exception
|
||||
{
|
||||
assertThat(TagsService.normalize(" Priority:HIGH today ")).isEqualTo("priority:high today");
|
||||
assertThat(TagsService.normalize("a".repeat(60))).hasSize(60);
|
||||
assertThatThrownBy(() -> TagsService.normalize("a".repeat(61))).hasMessageContaining("60");
|
||||
assertThatThrownBy(() -> TagsService.normalize(null)).hasMessageContaining("60");
|
||||
assertThatThrownBy(() -> TagsService.normalize(" ")).hasMessageContaining("60");
|
||||
TagEntry tag = tags.create("document", "Urgent", "red");
|
||||
assertThat(tag.name()).isEqualTo("urgent");
|
||||
assertThat(tag.label()).isEqualTo("Urgent");
|
||||
assertThat(tag.color()).isEqualTo("red");
|
||||
assertThat(tag.tableName()).isEqualTo("document");
|
||||
assertThat(tag.approved()).isTrue();
|
||||
assertThat(tag.usageCount()).isZero();
|
||||
assertThat(tags.create("document", " urgent ", null).id()).isEqualTo(tag.id());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Approval-only never permits applying a pending tag, including its author.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void suggestionsAndUnknownLabels() throws Exception
|
||||
{
|
||||
config.withDefaultTagMode("APPROVED_ONLY");
|
||||
TagEntry pending = tags.create("document", "Suggested", null);
|
||||
assertThat(pending.approved()).isFalse();
|
||||
assertThatThrownBy(() -> tags.apply("document", 1, List.of(pending.id()), List.of())).hasMessageContaining("Pick an approved tag");
|
||||
assertThatThrownBy(() -> tags.apply("document", 1, List.of("Unknown"), List.of())).hasMessageContaining("Pick an approved tag");
|
||||
assertThat(rows(CollaborationTables.TAG)).hasSize(1);
|
||||
login("bob");
|
||||
assertThat(tags.vocabulary("document", null, 20)).isEmpty();
|
||||
assertThatThrownBy(() -> tags.create("document", "Suggested", null)).hasMessageContaining("reserved");
|
||||
login("alice", TagsService.MANAGE);
|
||||
assertThat(tags.create("document", "Managed", null).approved()).isTrue();
|
||||
tags.approve(pending.id());
|
||||
tags.apply("document", 1, List.of(pending.id()), List.of());
|
||||
assertThat(tags.tagsFor("document", 1)).extracting(TagEntry::id).containsExactly(pending.id());
|
||||
config.withDefaultTagMode("FREE");
|
||||
tags.apply("document", 2, List.of("New on the fly"), List.of());
|
||||
assertThat(tags.tagsFor("document", 2)).extracting(TagEntry::name).containsExactly("new on the fly");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Global vocabulary and management demand the explicit permission.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void globalsAndManagementPermissions() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> tags.create(null, "Global", null)).isInstanceOf(QPermissionDeniedException.class);
|
||||
TagEntry own = tags.create("document", "Local", null);
|
||||
assertThatThrownBy(() -> tags.approve(own.id())).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> tags.delete(own.id())).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> tags.merge(own.id(), own.id())).isInstanceOf(QPermissionDeniedException.class);
|
||||
login("manager", TagsService.MANAGE);
|
||||
TagEntry global = tags.create(null, "Global", null);
|
||||
assertThat(global.tableName()).isNull();
|
||||
assertThat(global.approved()).isTrue();
|
||||
assertThat(tags.vocabulary("document", "glo", null)).extracting(TagEntry::id).containsExactly(global.id());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invisible parents return Not found; read-only users need an explicit grant.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void parentAndFeatureSecurity() throws Exception
|
||||
{
|
||||
TagEntry tag = tags.create("document", "A", null);
|
||||
assertThatThrownBy(() -> tags.tagsFor("document", 3)).isInstanceOf(QNotFoundException.class).hasMessage("Not found");
|
||||
assertThatThrownBy(() -> tags.apply("document", 3, List.of(tag.id()), List.of())).isInstanceOf(QNotFoundException.class);
|
||||
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("reader")).withPermissions("document.read").withSecurityKeyValue("tenant", "a"));
|
||||
assertThatThrownBy(() -> tags.create("document", "No", null)).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> tags.apply("document", 1, List.of(tag.id()), List.of())).isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.getQSession().withPermissions("document.tag");
|
||||
tags.create("document", "Granted", null);
|
||||
tags.apply("document", 1, List.of(tag.id()), List.of());
|
||||
assertThat(tags.tagsFor("document", 1)).hasSize(1);
|
||||
assertThatThrownBy(() -> tags.vocabulary("missing", null, 1)).isInstanceOf(QNotFoundException.class);
|
||||
assertThatThrownBy(() -> tags.create("missing", "No", null)).isInstanceOf(QNotFoundException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeated add/remove does not inflate counts or produce duplicate activity.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void idempotentAssignmentsCountsAndActivity() throws Exception
|
||||
{
|
||||
TagEntry tag = tags.create("document", "A", null);
|
||||
tags.apply("document", "01", List.of(tag.id(), tag.id()), List.of());
|
||||
tags.apply("document", 1, List.of("A"), null);
|
||||
tags.apply("document", 2, List.of(tag.id()), List.of());
|
||||
assertThat(rows(CollaborationTables.RECORD_TAG)).hasSize(2);
|
||||
assertThat(tags.vocabulary("document", null, 20).getFirst().usageCount()).isEqualTo(2);
|
||||
assertThat(rows(CollaborationTables.ACTIVITY)).extracting(row -> row.getValueString("type")).containsExactly("TAGGED", "TAGGED");
|
||||
assertThat(rows(CollaborationTables.FOLLOW_DIGEST)).isEmpty();
|
||||
tags.apply("document", 1, null, List.of(tag.id()));
|
||||
tags.apply("document", 1, List.of(), List.of("A"));
|
||||
assertThat(tags.tagsFor("document", 1)).isEmpty();
|
||||
assertThat(tags.vocabulary("document", null, 20).getFirst().usageCount()).isEqualTo(1);
|
||||
assertThat(rows(CollaborationTables.ACTIVITY)).hasSize(3);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** TABLE wins over APP then INSTANCE then the code default, with caller restored.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void settingsHierarchyReadAsSystem() throws Exception
|
||||
{
|
||||
instance.addApp(new QAppMetaData().withName("work").withLabel("Work").withChild(instance.getTable("document")).withSection(new QAppSection().withName("main").withTables(List.of("document"))));
|
||||
assertThat(tags.mode("document")).isEqualTo("FREE");
|
||||
setting("INSTANCE", "*", "APPROVED_ONLY");
|
||||
assertThat(tags.mode("document")).isEqualTo("APPROVED_ONLY");
|
||||
setting("APP", "work", "FREE");
|
||||
assertThat(tags.mode("document")).isEqualTo("FREE");
|
||||
setting("TABLE", "document", "APPROVED_ONLY");
|
||||
assertThat(tags.create("document", "Pending", null).approved()).isFalse();
|
||||
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo("alice");
|
||||
assertThat(QContext.getQSession().hasPermission("collaboration.admin")).isFalse();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Vocabulary ordering, scope, limits and private suggestions are stable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void vocabularyOrderingScopeAndSuggestions() throws Exception
|
||||
{
|
||||
TagEntry z = tags.create("document", "Zulu", null);
|
||||
TagEntry b = tags.create("document", "Beta", null);
|
||||
TagEntry a = tags.create("document", "Alpha", null);
|
||||
tags.apply("document", 1, List.of(z.id()), List.of());
|
||||
config.withDefaultTagMode("APPROVED_ONLY");
|
||||
TagEntry pending = tags.create("document", "Mine", null);
|
||||
login("bob");
|
||||
tags.create("document", "Other", null);
|
||||
login("alice");
|
||||
assertThat(tags.vocabulary("document", null, 1000)).extracting(TagEntry::id).containsExactly(z.id(), a.id(), b.id(), pending.id());
|
||||
assertThat(tags.vocabulary("document", " ALP ", 10)).extracting(TagEntry::id).containsExactly(a.id());
|
||||
assertThat(tags.vocabulary("document", null, 0)).hasSize(1);
|
||||
assertThat(tags.vocabulary("document", null, 2)).hasSize(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Merge deduplicates assignments, preserves aliases and rejects invalid scope.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mergeDeleteAndParentCleanup() throws Exception
|
||||
{
|
||||
TagEntry source = tags.create("document", "Source", null);
|
||||
TagEntry target = tags.create("document", "Target", null);
|
||||
tags.apply("document", 1, List.of(source.id(), target.id()), List.of());
|
||||
tags.apply("document", 2, List.of(source.id()), List.of());
|
||||
login("manager", TagsService.MANAGE);
|
||||
assertThatThrownBy(() -> tags.merge(source.id(), source.id())).hasMessageContaining("different");
|
||||
TagEntry global = tags.create(null, "Global", null);
|
||||
assertThatThrownBy(() -> tags.merge(global.id(), target.id())).hasMessageContaining("scope");
|
||||
tags.merge(source.id(), target.id());
|
||||
assertThat(tags.tagsFor("document", 1)).extracting(TagEntry::id).containsExactly(target.id());
|
||||
assertThat(tags.tagsFor("document", 2)).extracting(TagEntry::id).containsExactly(target.id());
|
||||
assertThat(tags.vocabulary("document", null, 20)).extracting(TagEntry::id).doesNotContain(source.id());
|
||||
assertThat(rows(CollaborationTables.TAG)).filteredOn(row -> row.getValueInteger("id").equals(source.id()))
|
||||
.singleElement().satisfies(row -> { assertThat(row.getValueInteger("usageCount")).isZero(); assertThat(row.getValueInteger("mergedIntoTagId")).isEqualTo(target.id()); });
|
||||
assertThatThrownBy(() -> tags.create("document", "Source", null)).hasMessageContaining("reserved");
|
||||
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
|
||||
new AttachmentCleanup().onParentsDeleted("document", List.of(1));
|
||||
new AttachmentCleanup().onParentsDeleted("document", List.of(1));
|
||||
assertThat(tags.vocabulary("document", null, 20).getFirst().usageCount()).isEqualTo(1);
|
||||
tags.delete(target.id());
|
||||
assertThat(rows(CollaborationTables.RECORD_TAG)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All translator operators use visible parent ids, with set semantics.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void translatorOperators() throws Exception
|
||||
{
|
||||
TagEntry a = tags.create("document", "A", null);
|
||||
TagEntry b = tags.create("document", "B", null);
|
||||
tags.apply("document", 1, List.of(a.id(), b.id()), List.of());
|
||||
tags.apply("document", 2, List.of(a.id()), List.of());
|
||||
assertThat(filters.recordIds("document", "IN", List.of("A"))).containsExactly("1", "2");
|
||||
assertThat(filters.recordIds("document", "IN", List.of(b.id()))).containsExactly("1");
|
||||
assertThat(filters.recordIds("document", "CONTAINS_ALL", List.of(a.id(), b.id(), a.id()))).containsExactly("1");
|
||||
assertThat(filters.recordIds("document", "CONTAINS_ALL", List.of(a.id(), "unknown"))).isEmpty();
|
||||
assertThat(filters.recordIds("document", "NOT_IN", List.of(b.id()))).containsExactly("2");
|
||||
assertThat(filters.recordIds("document", "IS_NOT_BLANK", null)).containsExactly("1", "2");
|
||||
assertThat(filters.recordIds("document", "IS_BLANK", null)).isEmpty();
|
||||
tags.apply("document", 2, List.of(), List.of(a.id()));
|
||||
assertThat(filters.recordIds("document", "IS_BLANK", List.of())).containsExactly("2");
|
||||
assertThat(filters.recordIds("document", "IN", List.of())).isEmpty();
|
||||
assertThat(filters.recordIds("document", "NOT_IN", List.of("unknown"))).containsExactly("1", "2");
|
||||
assertThat(filters.recordIds("document", "CONTAINS_ALL", List.of())).containsExactly("1", "2");
|
||||
assertThatThrownBy(() -> filters.recordIds("document", "LIKE", List.of())).hasMessageContaining("Unsupported");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exactly 10,000 is allowed; the next matching id fails instead of truncating.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void translatorCap() throws Exception
|
||||
{
|
||||
List<QRecord> parents = new ArrayList<>();
|
||||
for(int id = 4; id <= 10001; id++)
|
||||
{
|
||||
parents.add(new QRecord().withValue("id", id).withValue("tenant", "a"));
|
||||
}
|
||||
new InsertAction().execute(new InsertInput("document").withRecords(parents));
|
||||
assertThat(filters.recordIds("document", "IS_BLANK", null)).hasSize(10000);
|
||||
new InsertAction().execute(new InsertInput("document").withRecords(List.of(new QRecord().withValue("id", 10002).withValue("tenant", "a"))));
|
||||
assertThatThrownBy(() -> filters.recordIds("document", "IS_BLANK", null)).hasMessageContaining("10,000");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct access cannot bypass vocabulary approval or parent lock restrictions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableSecurityAndHome() throws Exception
|
||||
{
|
||||
TagEntry tag = tags.create("document", "<Urgent>", null);
|
||||
tags.apply("document", 1, List.of(tag.id()), List.of());
|
||||
config.withDefaultTagMode("APPROVED_ONLY");
|
||||
TagEntry pending = tags.create("document", "Pending", null);
|
||||
login("bob", "collaboration.view");
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.TAG)).getRecords()).hasSize(1);
|
||||
assertThat(new CountAction().execute(new CountInput(CollaborationTables.TAG)).getCount()).isEqualTo(1);
|
||||
assertThatThrownBy(() -> new GetAction().execute(new GetInput(CollaborationTables.TAG).withPrimaryKey(pending.id()))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(new GetAction().execute(new GetInput(CollaborationTables.TAG).withPrimaryKey(tag.id())).getRecord()).isNotNull();
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(CollaborationTables.TAG).withRecords(List.of(new QRecord())))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.TAG).withRecords(List.of(new QRecord().withValue("id", tag.id()))))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(CollaborationTables.TAG).withPrimaryKeys(List.of(tag.id())))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_TAG)).getRecords()).hasSize(1);
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b"));
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_TAG)).getRecords()).isEmpty();
|
||||
login("manager", "collaboration.admin", TagsService.MANAGE);
|
||||
String html = CollaborationHome.render(Instant.now());
|
||||
assertThat(html).contains("Vocabulary size: 2", "Pending suggestions: 1", "<Urgent>: 1");
|
||||
assertThat(html).doesNotContain("<Urgent>");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata, nullable uniqueness and scoped PVS are validated together.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void schemaAndMetadata() throws Exception
|
||||
{
|
||||
TagProcesses.processesFor("document").forEach(instance::addProcess);
|
||||
new QInstanceValidator().validate(instance);
|
||||
assertThat(instance.getTable(CollaborationTables.TAG).getSearchFields()).containsExactly("label");
|
||||
assertThat(instance.getTable(CollaborationTables.TAG).getSections()).anySatisfy(section -> assertThat(section.getLabel()).isEqualTo("Record Tags"));
|
||||
assertThat(instance.getPossibleValueSource(CollaborationTables.TAG).getTableName()).isEqualTo(CollaborationTables.TAG);
|
||||
QFrontendStepMetaData pick = (QFrontendStepMetaData) instance.getProcess("collaborationAddTags_document").getStep("tags");
|
||||
assertThat(pick.getFormFields().getFirst().getPossibleValueSourceName()).isEqualTo(CollaborationTables.TAG);
|
||||
assertThat(pick.getComponents().getFirst().getValues().get("multiSelectFields")).isEqualTo(List.of("tags"));
|
||||
assertThat(CollaborationSchema.postgresScript(config)).contains("ON collaboration_tag (table_name, name) NULLS NOT DISTINCT", "ON collaboration_record_tag (table_name, record_id, tag_id)", "usage_count INTEGER NOT NULL", "approved BOOLEAN NOT NULL");
|
||||
assertThatThrownBy(() -> config.withDefaultTagMode("INVALID").validate(instance)).hasMessageContaining("defaultTagMode");
|
||||
config.withDefaultTagMode("FREE").withTagActionTables(List.of("missing"));
|
||||
assertThatThrownBy(() -> config.validate(instance)).hasMessageContaining("host table");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bulk actions execute genuine Review and Result steps with idempotent counts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bulkActionsReviewAndResult() throws Exception
|
||||
{
|
||||
TagProcesses.processesFor("document").forEach(instance::addProcess);
|
||||
TagEntry a = tags.create("document", "A", null);
|
||||
TagEntry b = tags.create("document", "B", null);
|
||||
tags.apply("document", 1, List.of(a.id(), b.id()), List.of());
|
||||
RunProcessOutput review = review("collaborationAddTags_document", "1,2", Map.of(TagProcesses.TAGS, new ArrayList<>(List.of(a.id(), b.id()))));
|
||||
assertThat(lines(review, false)).anySatisfy(line -> { assertThat(line.getCount()).isEqualTo(2); assertThat(line.getPluralFutureMessage()).contains("Add 2 tags"); });
|
||||
assertThat(lines(review, false)).anySatisfy(line -> { assertThat(line.getCount()).isEqualTo(1); assertThat(line.getPluralFutureMessage()).contains("already"); });
|
||||
assertThat(tags.tagsFor("document", 2)).isEmpty();
|
||||
assertThat(lines(finish(review), true)).anySatisfy(line -> assertThat(line.getCount()).isEqualTo(2));
|
||||
assertThat(tags.tagsFor("document", 2)).hasSize(2);
|
||||
finish(review("collaborationRemoveTags_document", "1,2", Map.of(TagProcesses.TAGS, a.id() + "," + b.id())));
|
||||
assertThat(rows(CollaborationTables.RECORD_TAG)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Management Review counts affected records; Result performs each operation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void managementActionsReviewAndResult() throws Exception
|
||||
{
|
||||
config.withDefaultTagMode("APPROVED_ONLY");
|
||||
TagEntry pending = tags.create("document", "Pending", null);
|
||||
login("manager", TagsService.MANAGE);
|
||||
RunProcessOutput approve = review("collaborationApproveTags", pending.id().toString(), Map.of());
|
||||
assertThat(lines(approve, false)).anySatisfy(line -> assertThat(line.getPluralFutureMessage()).contains("Approve"));
|
||||
finish(approve);
|
||||
tags.apply("document", 1, List.of(pending.id()), List.of());
|
||||
TagEntry target = tags.create("document", "Target", null);
|
||||
RunProcessOutput merge = review("collaborationMergeTags", pending.id().toString(), Map.of(TagProcesses.TARGET, target.id()));
|
||||
finish(merge);
|
||||
assertThat(tags.tagsFor("document", 1)).extracting(TagEntry::id).containsExactly(target.id());
|
||||
RunProcessOutput delete = review("collaborationDeleteTags", target.id().toString(), Map.of());
|
||||
assertThat(lines(delete, false)).anySatisfy(line -> { assertThat(line.getCount()).isEqualTo(1); assertThat(line.getPluralFutureMessage()).contains("lose tag assignments"); });
|
||||
assertThat(lines(finish(delete), true)).anySatisfy(line -> assertThat(line.getPluralPastMessage()).contains("processed"));
|
||||
assertThat(tags.tagsFor("document", 1)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist one scoped feature policy as a trusted fixture setup.
|
||||
*******************************************************************************/
|
||||
private void setting(String scope, String name, String mode) throws Exception
|
||||
{
|
||||
CollaborationRecords.insert(CollaborationTables.FEATURE_SETTING, new QRecord().withValue("scopeType", scope).withValue("scopeName", name)
|
||||
.withValue("feature", "TAGS").withValue("enabled", true).withValue("settings", "{\"mode\":\"" + mode + "\"}"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect persistence to assert no hidden writes or duplicate activity.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows(String table) throws Exception
|
||||
{
|
||||
return (CollaborationRecords.query(table, new QQueryFilter()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Supply picker values and stop at the genuine standard Review screen.
|
||||
*******************************************************************************/
|
||||
private RunProcessOutput review(String name, String records, Map<String, Serializable> values) throws Exception
|
||||
{
|
||||
new QInstanceValidator().validate(instance);
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(name);
|
||||
input.addValue("testProcessName", name);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
input.addValue("recordsParam", "recordIds");
|
||||
input.addValue("recordIds", records);
|
||||
values.forEach(input::addValue);
|
||||
if(instance.getProcess(name).getStep("tags") != null)
|
||||
{
|
||||
RunProcessOutput picker = new RunProcessAction().execute(input);
|
||||
assertThat(picker.getException()).isEmpty();
|
||||
input.setProcessUUID(picker.getProcessUUID());
|
||||
input.setStartAfterStep("tags");
|
||||
}
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
return (output);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resume the stored process state after Review.
|
||||
*******************************************************************************/
|
||||
private RunProcessOutput finish(RunProcessOutput review) throws Exception
|
||||
{
|
||||
RunProcessInput go = new RunProcessInput();
|
||||
go.setProcessName((String) review.getValues().get("testProcessName"));
|
||||
go.setProcessUUID(review.getProcessUUID());
|
||||
go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW);
|
||||
go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
RunProcessOutput result = new RunProcessAction().execute(go);
|
||||
assertThat(result.getException()).isEmpty();
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary values are supplied by the streamed process engine.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ProcessSummaryLine> lines(RunProcessOutput output, boolean result)
|
||||
{
|
||||
return ((List<ProcessSummaryLine>) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY));
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user