diff --git a/CHANGELOG.md b/CHANGELOG.md
index 707495d..5c56830 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@ 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.
diff --git a/README.md b/README.md
index cc42043..9bb0d40 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# QBit Collaboration
-Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and standalone filter translation. Phases f to i remain planned; general runtime overrides are still deferred. TAGS mode settings are read directly by the service.
+Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and 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).
@@ -54,9 +54,13 @@ Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation
| `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. |
-All eleven include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold.
+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.
@@ -281,3 +285,107 @@ share a name, the global tag wins (use IDs to disambiguate).
phase does not register a virtual filter field, core expander, `QSubQuery`, tag
column filler, or `#tag`/host-record quick-search provider. The standalone
translator deliberately imports none of those unreleased types.
+
+## 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 `
.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
+`.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.
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index bbac562..ed3c434 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -2,7 +2,7 @@
The authoritative design is [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md` ([develop document](https://github.com/QRun-IO/qqq/blob/develop/docs/COLLABORATION.md)).
-Collaboration adds metadata-driven record features to QQQ: attachments, tags, custom fields, comments and mentions, presence, sharing, favorites, follow, timeline, recents, tasks and assignment. Core owns security, query and runtime metadata contracts; this qbit owns visible `collaboration_*` tables and feature services. Implemented: phase-a configuration, feature settings and people tables, plus phase-b favorites/recents tables, services, owner locks, retention and home counts against QQQ 4.1.0-RC.5. The phase-b UI API endpoints await the released extension point from QRun-IO/qqq#1128. The override and record-feature summary providers await released core contracts; phase-c attachments are implemented on object storage 0.1.0-RC.2; phase e comments/follow/timeline is implemented against released RC.5; phase d and phases f 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 |
|---|---|---|---|
@@ -82,3 +82,39 @@ The service API and configuration are documented in [README](../README.md#commen
- **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.
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java
index ef02284..6a62ab7 100644
--- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java
+++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java
@@ -44,6 +44,9 @@ public class CollaborationQBitConfig implements QBitConfig
private List tagActionTables = List.of();
private List followActionTables = List.of();
private List followChangeTables = List.of();
+ private String frontendBaseUrl;
+ private QCodeReference permissionHolderDirectory;
+ private List assignActionTables = List.of();
private String backendName;
private String schedulerName;
private String appName = "collaboration";
@@ -254,7 +257,7 @@ public class CollaborationQBitConfig implements QBitConfig
}
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 tables : List.of(followActionTables, followChangeTables, tagActionTables))
+ for(List tables : List.of(followActionTables, followChangeTables, tagActionTables, assignActionTables))
{
for(String table : tables)
{
@@ -476,4 +479,55 @@ public class CollaborationQBitConfig implements QBitConfig
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 getAssignActionTables()
+ {
+ return (assignActionTables);
+ }
+
+ /*******************************************************************************
+ ** Configure assignActionTables.
+ *******************************************************************************/
+ public CollaborationQBitConfig withAssignActionTables(List value)
+ {
+ this.assignActionTables = value;
+ return (this);
+ }
}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java
index 3ebf4aa..65b033c 100644
--- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java
+++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java
@@ -128,6 +128,13 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer query(String table, QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(QContext.getQBackendTransaction());
- input.setTableMetaData(QContext.getQInstance().getTable(table).clone().withCustomizers(null));
+ input.setTableMetaData(serviceMetadata(table));
input.withPermissionBypass("Trusted collaboration service performs parent/ownership authorization.");
return (new QueryAction().execute(input).getRecords().stream().map(QRecord::new).toList());
}
@@ -85,6 +86,7 @@ public final class CollaborationRecords
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 result = new InsertAction().execute(input).getRecords();
check(result);
@@ -97,7 +99,7 @@ public final class CollaborationRecords
public static void update(String table, QRecord row) throws QException
{
UpdateInput input = new UpdateInput(table).withRecords(List.of(row)).withTransaction(QContext.getQBackendTransaction());
- input.setTableMetaData(QContext.getQInstance().getTable(table).clone().withCustomizers(null));
+ input.setTableMetaData(serviceMetadata(table));
input.withPermissionBypass("Trusted collaboration service authorized this write.");
check(new UpdateAction().execute(input).getRecords());
}
@@ -108,11 +110,26 @@ public final class CollaborationRecords
public static void delete(String table, Serializable id) throws QException
{
DeleteInput input = new DeleteInput(table).withPrimaryKeys(List.of(id)).withTransaction(QContext.getQBackendTransaction());
- input.setTableMetaData(QContext.getQInstance().getTable(table).clone().withCustomizers(null));
+ input.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.
*******************************************************************************/
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java
index b3eff7b..7d117f1 100644
--- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java
+++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSchema.java
@@ -77,6 +77,18 @@ public final class CollaborationSchema
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");
}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java b/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java
index 2bfec25..c7ac29e 100644
--- a/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java
+++ b/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java
@@ -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"));
}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/WorkCleanup.java b/src/main/java/com/kingsrook/qbits/collaboration/WorkCleanup.java
new file mode 100644
index 0000000..485d2c1
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/WorkCleanup.java
@@ -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 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 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"));
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/WorkProcesses.java b/src/main/java/com/kingsrook/qbits/collaboration/WorkProcesses.java
new file mode 100644
index 0000000..fc5a28e
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/WorkProcesses.java
@@ -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 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 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 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 getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
+ {
+ ArrayList 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 getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
+ {
+ ArrayList lines = new ArrayList<>();
+ done.addSelfToListIfAnyCount(lines);
+ failed.addSelfToListIfAnyCount(lines);
+ if(getTransformStep() instanceof Review review)
+ {
+ review.skipped.addSelfToListIfAnyCount(lines);
+ }
+ return (lines);
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/WorkSupport.java b/src/main/java/com/kingsrook/qbits/collaboration/WorkSupport.java
new file mode 100644
index 0000000..03a4758
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/WorkSupport.java
@@ -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 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."));
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java
index 0145630..af381da 100644
--- a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java
+++ b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java
@@ -83,7 +83,7 @@ public final class CollaborationHome
.withPermissionRules(CollaborationPermissions.permissionRules())
.withWidgets(List.of(HOME_WIDGET))
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
- .withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.ACTIVITY, 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);
}
@@ -142,6 +142,15 @@ public final class CollaborationHome
personalSummary(html, CollaborationTables.RECENT, "Recently viewed");
attachmentSummary(html);
tagSummary(html);
+ html.append("Open access requests
").append(count(CollaborationTables.ACCESS_REQUEST,
+ new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("
");
+ html.append("Open tasks
").append(count(CollaborationTables.TASK,
+ new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("
");
+ html.append("Overdue tasks
").append(count(CollaborationTables.TASK,
+ new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN").withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now))).append("
");
+ html.append("Shares this week
").append(count(CollaborationTables.SHARE,
+ new QQueryFilter().withCriteria("sentAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
+ .withCriteria("sentAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))).append("
");
html.append("Comments this week
").append(count(CollaborationTables.COMMENT, new QQueryFilter()
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
.withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("
");
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/assignment/AssignmentService.java b/src/main/java/com/kingsrook/qbits/collaboration/assignment/AssignmentService.java
new file mode 100644
index 0000000..110ed0e
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/assignment/AssignmentService.java
@@ -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 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 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 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> groups = new LinkedHashMap<>();
+ for(QRecord row : mineRows(table, false))
+ {
+ groups.computeIfAbsent(row.getValueString("tableName"), key -> new ArrayList<>()).add(row);
+ }
+ Map 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 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 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 records)
+ {
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java
index cd35539..c12792d 100644
--- a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java
+++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java
@@ -61,6 +61,7 @@ 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)
{
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java
index 0f7b30a..bd5aaad 100644
--- a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java
+++ b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java
@@ -58,6 +58,10 @@ 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";
@@ -93,6 +97,10 @@ public final class CollaborationTables
public static List 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"),
@@ -165,6 +173,7 @@ public final class CollaborationTables
List 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"));
@@ -294,6 +303,7 @@ public final class CollaborationTables
.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")
@@ -382,4 +392,60 @@ public final class CollaborationTables
}
return (List.of(tags, links));
}
+
+ /*******************************************************************************
+ ** Sharing and work tables remain visible, audited and service-managed.
+ *******************************************************************************/
+ private static List workTables(CollaborationQBitConfig config)
+ {
+ QTableMetaData shares = referenced(config, SHARE, "Shares", "share")
+ .withField(string("fromUserId", "From", 255).withIsRequired(true))
+ .withField(string("toUserId", "To", 255).withIsRequired(true))
+ .withField(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 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);
+ }
}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/mywork/MyWorkService.java b/src/main/java/com/kingsrook/qbits/collaboration/mywork/MyWorkService.java
new file mode 100644
index 0000000..cbe3a62
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/mywork/MyWorkService.java
@@ -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 tasks = new TasksService().mineRows("OPEN");
+ Map 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 unread() throws QException
+ {
+ List 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 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 tasks, Map assignedRecordsByTable, Long unreadMentionCount)
+ {
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java b/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java
index 5084a0c..d47caa2 100644
--- a/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java
+++ b/src/main/java/com/kingsrook/qbits/collaboration/notify/Notifications.java
@@ -21,8 +21,6 @@
package com.kingsrook.qbits.collaboration.notify;
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.security.OtherUserAccess;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
@@ -56,6 +54,22 @@ public final class Notifications
** Optional receipt runs only after actual transport delivery, never on rollback.
*******************************************************************************/
public static void send(CollaborationNotification notification, UnsafeVoidVoidMethod 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 receipt, boolean checkParent) throws QException
{
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
if(QContext.getQBackendTransaction() != null)
@@ -64,7 +78,7 @@ public final class Notifications
{
try
{
- QContext.withTemporaryContext(context, () -> deliver(notification, receipt));
+ QContext.withTemporaryContext(context, () -> deliver(notification, receipt, checkParent));
}
catch(QException e)
{
@@ -74,16 +88,16 @@ public final class Notifications
}
else
{
- deliver(notification, receipt);
+ deliver(notification, receipt, checkParent);
}
}
/*******************************************************************************
** Re-check immediately before exposing record data to the transport.
*******************************************************************************/
- private static void deliver(CollaborationNotification notification, UnsafeVoidVoidMethod receipt) throws QException
+ private static void deliver(CollaborationNotification notification, UnsafeVoidVoidMethod receipt, boolean checkParent) throws QException
{
- if(new OtherUserAccess().canRead(notification.recipientUserId(), notification.sourceTableName(), notification.sourceRecordId()).orElse(false))
+ 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);
@@ -99,7 +113,6 @@ public final class Notifications
*******************************************************************************/
public static String link(String table, String id)
{
- return ("/app/" + URLEncoder.encode(table, StandardCharsets.UTF_8).replace("+", "%20") + "/"
- + URLEncoder.encode(id, StandardCharsets.UTF_8).replace("+", "%20"));
+ return (com.kingsrook.qbits.collaboration.TargetResolver.recordHref(table, id));
}
}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java
index f2ad757..cfd7e48 100644
--- a/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java
+++ b/src/main/java/com/kingsrook/qbits/collaboration/security/CollaborationRowSecurity.java
@@ -52,7 +52,7 @@ public class CollaborationRowSecurity implements CustomPermissionChecker
}
new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata);
if(metadata instanceof QTableMetaData table
- && (table.getName().equals(CollaborationTables.FOLLOW) || table.getName().equals(CollaborationTables.ACTIVITY) || table.getName().equals(CollaborationTables.FOLLOW_DIGEST))
+ && (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."));
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/sharing/AccessRequestSecurity.java b/src/main/java/com/kingsrook/qbits/collaboration/sharing/AccessRequestSecurity.java
new file mode 100644
index 0000000..a498b30
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/sharing/AccessRequestSecurity.java
@@ -0,0 +1,70 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package com.kingsrook.qbits.collaboration.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 postQuery(QueryOrGetInputInterface input, List 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);
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/sharing/AccessRequestService.java b/src/main/java/com/kingsrook/qbits/collaboration/sharing/AccessRequestService.java
new file mode 100644
index 0000000..2cae80f
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/sharing/AccessRequestService.java
@@ -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 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 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));
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/sharing/PermissionHolderDirectory.java b/src/main/java/com/kingsrook/qbits/collaboration/sharing/PermissionHolderDirectory.java
new file mode 100644
index 0000000..493d6a5
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/sharing/PermissionHolderDirectory.java
@@ -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 userIdsWithPermission(String permission) throws QException;
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java b/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java
new file mode 100644
index 0000000..9af07fa
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/sharing/SharingService.java
@@ -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 preview(String table, Serializable recordId, List 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 recipients = new ArrayList<>();
+ for(String user : toUserIds.stream().distinct().toList())
+ {
+ Optional access = user.equals(PersonalRecords.currentUserId()) ? Optional.of(true) : new OtherUserAccess().canRead(user, table, recordId);
+ 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 share(String table, Serializable recordId, List toUserIds, String message) throws QException
+ {
+ WorkSupport.text(message, 2000, false);
+ List 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 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)
+ {
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tasks/SendTaskRemindersProcess.java b/src/main/java/com/kingsrook/qbits/collaboration/tasks/SendTaskRemindersProcess.java
new file mode 100644
index 0000000..c2cbd5c
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/tasks/SendTaskRemindersProcess.java
@@ -0,0 +1,99 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package com.kingsrook.qbits.collaboration.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();
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tasks/TaskReminders.java b/src/main/java/com/kingsrook/qbits/collaboration/tasks/TaskReminders.java
new file mode 100644
index 0000000..a9afc0e
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/tasks/TaskReminders.java
@@ -0,0 +1,92 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2025. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package com.kingsrook.qbits.collaboration.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)));
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java b/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java
new file mode 100644
index 0000000..44718f0
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/collaboration/tasks/TasksService.java
@@ -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 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 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 rows = mineRows(status);
+ if(cursor != null)
+ {
+ QRecord after = decode(cursor);
+ rows = rows.stream().filter(row -> ORDER.compare(row, after) > 0).toList();
+ }
+ List 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 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 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 tasks, String nextCursor)
+ {
+ }
+}
diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java
index 682c019..ff2ee5f 100644
--- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java
+++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java
@@ -114,7 +114,7 @@ class CollaborationTest
CollaborationTables.SCOPE_TYPE, CollaborationTables.FEATURE, CollaborationTables.PERMISSION_MODE);
assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12);
assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName())
- .containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.ACTIVITY, 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,7 +177,7 @@ 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(13);
+ assertThat(headless.getTables()).hasSize(17);
assertThat(headless.getApps()).isEmpty();
assertThat(headless.getWidgets()).hasSize(4);
}
diff --git a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java
index 6764abf..1960fd2 100644
--- a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java
+++ b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java
@@ -493,7 +493,7 @@ public class ConversationServicesTest extends PersonalServiceFixture
new CollaborationQBitProducer().withQBitConfig(configured).produce(installed);
assertThat(installed.getProcess("collaborationFollow_document").getLabel()).isEqualTo("Follow");
assertThat(installed.getProcess("collaborationUnfollow_document").getLabel()).isEqualTo("Unfollow");
- assertThat(installed.getRecordChangeListeners()).hasSize(1);
+ 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);
diff --git a/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java b/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java
new file mode 100644
index 0000000..dd02e21
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/collaboration/SharingWorkTest.java
@@ -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 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 sessionFor(String user)
+ {
+ return (Optional.ofNullable(sessions.get(user)));
+ }
+ }
+
+ /*******************************************************************************
+ ** Directory.
+ *******************************************************************************/
+ public static class Directory implements PermissionHolderDirectory
+ {
+ /*******************************************************************************
+ ** Use the configured test boundary.
+ *******************************************************************************/
+ @Override
+ public Set 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 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 requests1", "Open tasks
1", "Overdue tasks
1", "Shares this week
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 rows(String table) throws Exception
+ {
+ return (CollaborationRecords.query(table, new QQueryFilter()));
+ }
+
+ /*******************************************************************************
+ ** Visible Requests.
+ *******************************************************************************/
+ private List visibleRequests() throws Exception
+ {
+ return (new QueryAction().execute(new QueryInput(CollaborationTables.ACCESS_REQUEST).withShouldGenerateDisplayValues(true)).getRecords());
+ }
+
+ /*******************************************************************************
+ ** Review.
+ *******************************************************************************/
+ private RunProcessOutput review(String name, String records, Map 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 lines(RunProcessOutput output, boolean result)
+ {
+ return ((List) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY));
+ }
+}