feat: sharing with access requests, tasks, owner/assignee and My work #9

Merged
KofTwentyTwo merged 1 commits from feature/sharing-tasks into develop 2026-10-14 00:09:32 +00:00
28 changed files with 2825 additions and 21 deletions

No files matched your search

+6
View File
@@ -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.
+110 -2
View File
@@ -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 `<table>.share` or EDIT,
1-25 recipients (deduplicated), and a message of at most 2,000 characters. Readers get the
record label/message/link and SHARED following. False/unknown recipients get only sender name,
table label and an access-request link. The sender's message is omitted because it may contain
record data. The prefill route for the follow-up UI is:
`/app/collaboration_access_request/create?tableName={encodedTable}&recordId={encodedId}&shareId={id}`
The UI consumes these parameters and calls `request`; a supplied share ID must match its
recipient and parent reference. Sharing never grants access. Parent-data notifications recheck
recipient access at delivery, after commit. Access notices contain no parent data and can reach
nonreaders.
Any signed-in user may request access, without a parent read or existence check. Existing and
nonexistent targets return the same request shape and OPEN status; generated IDs/timestamps
and submitted targets naturally differ. Duplicate OPEN requests return the existing row.
A PostgreSQL partial unique index protects that natural key across instances. Ordinary table
reads are requester/admin only; all direct writes are denied. Approver actions use an explicitly
authorized extractor without widening ordinary table visibility.
Set TABLE-scope SHARING `settings` to `{"accessApproverPermission":"order.approve"}` (default:
`collaboration.admin`). Configure `.withPermissionHolderDirectory(new QCodeReference(MyDirectory.class))`;
`sharing.PermissionHolderDirectory.userIdsWithPermission(permission)` returns stable user IDs.
Recipients are the union of admin holders, configured approver holders and the qbit record owner.
Without a directory, only the owner is notified and a warning is logged once per JVM. Mark granted
and Decline accept an optional note and tell the requester the outcome. Review explicitly says
"Record the outcome; grant access in your identity system first." No permission is changed.
Notifications omit free-form messages and notes.
Task create requires `task`. Changes additionally require creator, assignee or admin authority,
while retaining parent visibility and task permission checks. Titles contain 1-200 characters;
notes are markdown TEXT. An omitted create assignee defaults to self. Other assignees need a
positive read check; false/unknown is refused with "Sam can't open this record." The full-field
`update(id, title, notes, assignee, due, remind)` replaces editable fields: null clears an assignee
or due time; null remind computes the default from due. The reminder defaults to due minus one
day, or due itself when less than a day away. Assignments add ASSIGNED following and notify the
new assignee unless self. Creation/completion writes activity and queues notify-on-change follower
digests. Reopen clears completion fields but retains delivery history; changing due/reminder time
or assignee resets the relevant delivery receipt.
The hidden `collaborationSendTaskReminders` process runs every 60 seconds with `schedulerName`.
It requires an admin/system session and no open transaction. `TaskReminders(Clock)` and
`TasksService(Clock)` allow deterministic time. Separate timestamps track successful reminder
and overdue delivery; revoked/unknown access and the no-op notifier do not mark delivery.
Run one scheduler per installation. A crash between delivery and receipt can cause a retry;
stronger guarantees require transport idempotency, as with follow digests.
Configure `assignActionTables` for streamed Assign to and Take ownership actions. Assign needs
`<table>.assign` or EDIT and verifies both roles can read (self passes after the parent check).
`assign` replaces both roles, with null clearing a role; bulk actions preserve the other role.
For host tables with their own owner/assignee columns, use core `RecordOwnership` metadata and
normal host-table updates instead. Do not use the qbit assignment table or configure these bulk
actions for such tables. Core phase-i virtual fields, CurrentUser and filter integration remain
separate work; RC.5 does not provide that contract.
My work returns the exact visible open-task count and first ten, assigned-record counts per
table, and delivered unread mentions. Personal lists/counts recheck each parent. This portable
RC.5 implementation materializes matching candidates for null-last sorting/counting; large
installations should replace it with core mirrored-lock queries when available. Task cursors
encode `(dueAt, id)` and limits are 1-100. My records limits returned rows per table, counting all
visible matches.
The home adds open access requests, open/overdue tasks and shares in the last seven days.
Task/share aggregate counts retain the existing approximation for parent record locks until
core mirrors them. `WorkCleanup` is registered for committed host deletes: it removes tasks,
assignments and shares, retaining access requests. Attachment cleanup also invokes the
idempotent hook. Schema generation includes the four tables, supporting indexes and
`ALTER TABLE collaboration_mention ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ` for upgrades.
Hosts apply generated SQL through their migration tooling; ESB/search publication uses the same
host integrations as other collaboration tables.
+37 -1
View File
@@ -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.
@@ -44,6 +44,9 @@ public class CollaborationQBitConfig implements QBitConfig
private List<String> tagActionTables = List.of();
private List<String> followActionTables = List.of();
private List<String> followChangeTables = List.of();
private String frontendBaseUrl;
private QCodeReference permissionHolderDirectory;
private List<String> assignActionTables = List.of();
private String backendName;
private String schedulerName;
private String appName = "collaboration";
@@ -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<String> tables : List.of(followActionTables, followChangeTables, tagActionTables))
for(List<String> 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<String> getAssignActionTables()
{
return (assignActionTables);
}
/*******************************************************************************
** Configure assignActionTables.
*******************************************************************************/
public CollaborationQBitConfig withAssignActionTables(List<String> value)
{
this.assignActionTables = value;
return (this);
}
}
@@ -128,6 +128,13 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
CollaborationSecurity.buildSecurityKeyTypes().forEach(output::add);
output.add(TrimRecentsProcess.metadata(qBitConfig));
output.add(com.kingsrook.qbits.collaboration.tasks.SendTaskRemindersProcess.metadata(qBitConfig));
WorkProcesses.management().forEach(output::add);
for(String table : qBitConfig.getAssignActionTables())
{
WorkProcesses.assignments(table).forEach(output::add);
}
instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(WorkCleanup.class));
output.add(com.kingsrook.qbits.collaboration.follow.SendFollowDigestsProcess.metadata(qBitConfig));
for(String table : qBitConfig.getFollowActionTables())
{
@@ -36,6 +36,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Internal persistence for trusted services. Callers must authorize the parent
@@ -74,7 +75,7 @@ public final class CollaborationRecords
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(QContext.getQBackendTransaction());
input.setTableMetaData(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<QRecord> 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.
*******************************************************************************/
@@ -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");
}
@@ -296,6 +296,15 @@ public final class TargetResolver
/*******************************************************************************
** Shared canonical record href, with optional configured frontend origin.
*******************************************************************************/
public static String recordHref(String table, String id)
{
String base = CollaborationRecords.config().getFrontendBaseUrl();
return ((base == null ? "" : base.replaceAll("/+$", "")) + path(table) + encode(id));
}
/*******************************************************************************
** Flat Next router path; trailing slash also supports dotted process names.
*******************************************************************************/
@@ -309,7 +318,7 @@ public final class TargetResolver
/*******************************************************************************
** Encode a single route segment, including slash, hash and spaces in ids.
*******************************************************************************/
private static String encode(String value)
public static String encode(String value)
{
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"));
}
@@ -0,0 +1,111 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeEvent;
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListenerInterface;
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Trusted after-delete hook. Access requests deliberately survive parent deletion.
*******************************************************************************/
public class WorkCleanup implements RecordChangeListenerInterface
{
/*******************************************************************************
** Host deletions only; extension deletes must not recursively invoke cleanup.
*******************************************************************************/
@Override
public boolean appliesTo(String table, RecordChangeType type)
{
return (type == RecordChangeType.DELETE && !table.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX));
}
/*******************************************************************************
** Defer cleanup until the parent deletion commits.
*******************************************************************************/
@Override
public void onRecordsChanged(RecordChangeEvent event) throws QException
{
if(!appliesTo(event.getTableName(), event.getType()))
{
return;
}
String table = event.getTableName();
String key = QContext.getQInstance().getTable(table).getPrimaryKeyField();
List<Serializable> ids = event.getRecords().stream().map(row -> row.getValue(key)).toList();
if(event.getTransaction() == null)
{
onParentsDeleted(table, ids);
}
else
{
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
event.getTransaction().addAfterCommitCallback(() ->
{
try
{
QContext.withTemporaryContext(context, () -> onParentsDeleted(table, ids));
}
catch(QException e)
{
throw (new IllegalStateException("Could not clean up collaboration work", e));
}
});
}
}
/*******************************************************************************
** Bounded batches and idempotent deletion also support explicit host callbacks.
*******************************************************************************/
public void onParentsDeleted(String table, List<? extends Serializable> ids) throws QException
{
if(ids.isEmpty())
{
return;
}
for(String child : List.of(CollaborationTables.TASK, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.SHARE))
{
while(true)
{
List<QRecord> rows = CollaborationRecords.query(child, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()).withLimit(200));
if(rows.isEmpty())
{
break;
}
for(QRecord row : rows)
{
CollaborationRecords.delete(child, row.getValue("id"));
}
}
}
}
}
@@ -0,0 +1,373 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qbits.collaboration.sharing.AccessRequestService;
import com.kingsrook.qbits.collaboration.tasks.TasksService;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Streamed Review/Result actions. Services reauthorize all writes at load time.
*******************************************************************************/
public final class WorkProcesses
{
/*******************************************************************************
** Metadata factory.
*******************************************************************************/
private WorkProcesses()
{
}
/*******************************************************************************
** Record actions stay on their source tables, never in app navigation.
*******************************************************************************/
public static List<QProcessMetaData> management()
{
return (List.of(process(CollaborationTables.ACCESS_REQUEST, "Grant", "Mark granted"), process(CollaborationTables.ACCESS_REQUEST, "Decline", "Decline"),
process(CollaborationTables.TASK, "Done", "Mark done"), process(CollaborationTables.TASK, "Reopen", "Reopen")));
}
/*******************************************************************************
** Hosts opt in only tables without their own ownership columns.
*******************************************************************************/
public static List<QProcessMetaData> assignments(String table)
{
return (List.of(process(table, "Assign", "Assign to"), process(table, "Own", "Take ownership")));
}
/*******************************************************************************
** Optional notes and assignee input precede the standard Review screen.
*******************************************************************************/
private static QProcessMetaData process(String table, String operation, String label)
{
boolean access = table.equals(CollaborationTables.ACCESS_REQUEST);
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, access
? "Record the outcome; grant access in your identity system first." : label + " for the selected records.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(access ? AccessExtract.class : ExtractViaQueryStep.class, Review.class, Apply.class, values)
.withName("collaboration" + operation + "_" + table).withLabel(label).withTableName(table).withIcon(new QIcon("assignment"))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(ActionSecurity.class)));
if(access || operation.equals("Assign"))
{
QFieldMetaData field = new QFieldMetaData(access ? "note" : "assigneeUserId", access ? QFieldType.TEXT : QFieldType.STRING)
.withLabel(access ? "Note" : "Assignee user ID").withIsRequired(!access).withMaxLength(access ? 2000 : 255);
process.withStep(0, new QFrontendStepMetaData().withName("details").withLabel(label).withFormField(field)
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)));
}
return (process);
}
/*******************************************************************************
** Operation and table come from installed process metadata.
*******************************************************************************/
private static String operation(RunBackendStepInput input)
{
return (input.getProcess().getName().substring("collaboration".length()).split("_")[0]);
}
/*******************************************************************************
** Share the same dry-run authorization between Review and load.
*******************************************************************************/
private static void validate(RunBackendStepInput input, QRecord row) throws QException
{
String table = input.getProcess().getTableName();
if(table.equals(CollaborationTables.ACCESS_REQUEST))
{
new AccessRequestService().requireApprover(row.getValueInteger("id"));
WorkSupport.text(input.getValueString("note"), 2000, false);
}
else if(table.equals(CollaborationTables.TASK))
{
new TasksService().requireManager(row.getValueInteger("id"));
}
else
{
Serializable id = row.getValue(QContext.getQInstance().getTable(table).getPrimaryKeyField());
ParentRecordAccess.requireFeature(table, id, "assign");
if(operation(input).equals("Assign"))
{
WorkSupport.text(input.getValueString("assigneeUserId"), 255, true);
WorkSupport.requireReader(input.getValueString("assigneeUserId"), table, id);
}
}
}
/*******************************************************************************
** Preserve the role that the selected action does not modify.
*******************************************************************************/
private static void apply(RunBackendStepInput input, QRecord row) throws QException
{
validate(input, row);
String table = input.getProcess().getTableName();
String operation = operation(input);
if(table.equals(CollaborationTables.ACCESS_REQUEST))
{
new AccessRequestService().handle(row.getValueInteger("id"), operation.equals("Grant") ? "GRANTED" : "DECLINED", input.getValueString("note"));
}
else if(table.equals(CollaborationTables.TASK))
{
if(operation.equals("Done"))
{
new TasksService().complete(row.getValueInteger("id"));
}
else
{
new TasksService().reopen(row.getValueInteger("id"));
}
}
else
{
Serializable id = row.getValue(QContext.getQInstance().getTable(table).getPrimaryKeyField());
AssignmentService service = new AssignmentService();
QRecord old = service.get(table, id).orElse(new QRecord());
service.assign(table, id, operation.equals("Own") ? PersonalRecords.currentUserId() : old.getValueString("ownerUserId"),
operation.equals("Assign") ? input.getValueString("assigneeUserId") : old.getValueString("assigneeUserId"));
}
}
/*******************************************************************************
** Consistent Review and Result counts.
*******************************************************************************/
private static ProcessSummaryLine line(Status status, String future, String past)
{
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
line.setSingularFutureMessage(future);
line.setPluralFutureMessage(future);
line.setSingularPastMessage(past);
line.setPluralPastMessage(past);
return (line);
}
/*******************************************************************************
** Restrict action availability while keeping row authorization in the services.
*******************************************************************************/
public static class ActionSecurity implements CustomPermissionChecker
{
/*******************************************************************************
** Access actions require admin or a configured approver permission.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
PersonalRecords.currentUserId();
String table = ((QProcessMetaData) metadata).getTableName();
if(table.equals(CollaborationTables.ACCESS_REQUEST))
{
if(QContext.getQSession().hasPermission("collaboration.admin"))
{
return;
}
try
{
for(String host : QContext.getQInstance().getTables().keySet())
{
if(!host.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX)
&& QContext.getQSession().hasPermission(new AccessRequestService().approverPermission(host)))
{
return;
}
}
}
catch(QException e)
{
throw (new QPermissionDeniedException("Could not resolve access approver permission", e));
}
throw (new QPermissionDeniedException("This needs the access approver permission."));
}
if(!table.equals(CollaborationTables.TASK))
{
try
{
ParentRecordAccess.requireTableFeature(table, "assign");
}
catch(QException e)
{
throw (new QPermissionDeniedException("This needs the table assign permission or EDIT.", e));
}
}
}
}
/*******************************************************************************
** Approvers can process requests without broadening requester/admin table reads.
*******************************************************************************/
public static class AccessExtract extends ExtractViaQueryStep
{
/*******************************************************************************
** Only authorized rows enter the process pipe or appear in its preview.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
int count = 0;
for(QRecord row : CollaborationRecords.query(CollaborationTables.ACCESS_REQUEST, getQueryFilter(input)))
{
new AccessRequestService().requireApprover(row.getValueInteger("id"));
getRecordPipe().addRecord(row);
count++;
if(getLimit() != null && count >= getLimit())
{
break;
}
}
}
/*******************************************************************************
** Avoid exposing an unfiltered count outside the table owner lock.
*******************************************************************************/
@Override
public Integer doCount(RunBackendStepInput input)
{
return (null);
}
}
/*******************************************************************************
** Review validates recipient access and authority without writing.
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine selected = line(Status.OK, "records will be processed", "records processed");
private final ProcessSummaryLine skipped = line(Status.WARNING, "records unavailable (skipped)", "records skipped");
/*******************************************************************************
** Only validated rows advance to load.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(input.getProcess().getTableName().equals(CollaborationTables.ACCESS_REQUEST))
{
selected.setSingularFutureMessage("Record the outcome; grant access in your identity system first.");
selected.setPluralFutureMessage("Record the outcome; grant access in your identity system first.");
}
for(QRecord row : input.getRecords())
{
try
{
validate(input, row);
output.getRecords().add(row);
selected.incrementCountAndAddPrimaryKey(row.getValue(QContext.getQInstance().getTable(input.getProcess().getTableName()).getPrimaryKeyField()));
}
catch(QException expected)
{
skipped.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
}
}
/*******************************************************************************
** Explicit identity-system prerequisite accompanies access-request reviews.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
selected.addSelfToListIfAnyCount(lines);
skipped.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Result reports actual writes and any permission changes since Review.
*******************************************************************************/
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine done = line(Status.OK, "records will be processed", "records processed");
private final ProcessSummaryLine failed = line(Status.ERROR, "records cannot be processed", "records failed (retry required)");
/*******************************************************************************
** Each write goes through its service authorization again.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : input.getRecords())
{
try
{
apply(input, row);
done.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
catch(QException expected)
{
failed.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
}
}
/*******************************************************************************
** Retain Review skips alongside successful and failed loads.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
done.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
if(getTransformStep() instanceof Review review)
{
review.skipped.addSelfToListIfAnyCount(lines);
}
return (lines);
}
}
}
@@ -0,0 +1,96 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.collaboration.people.PeopleService;
import com.kingsrook.qbits.collaboration.security.OtherUserAccess;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Shared validation for service-managed work records.
*******************************************************************************/
public final class WorkSupport
{
/*******************************************************************************
** Static helpers only.
*******************************************************************************/
private WorkSupport()
{
}
/*******************************************************************************
** Look up an extension row and enforce its parent before returning data.
*******************************************************************************/
public static QRecord visible(String table, Integer id) throws QException
{
QRecord row = row(table, id);
if(Boolean.TRUE.equals(row.getValueBoolean("parentDeleted")))
{
throw (new QNotFoundException("Not found"));
}
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
return (row);
}
/*******************************************************************************
** Trusted lookup: callers must authorize ownership or parent access themselves.
*******************************************************************************/
public static QRecord row(String table, Integer id) throws QException
{
List<QRecord> rows = CollaborationRecords.query(table, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id));
if(rows.isEmpty())
{
throw (new QNotFoundException("Not found"));
}
return (rows.getFirst());
}
/*******************************************************************************
** Self passes after the caller's parent check; unknown others fail closed.
*******************************************************************************/
public static void requireReader(String user, String table, Serializable id) throws QException
{
if(user != null && (user.isBlank() || (!user.equals(PersonalRecords.currentUserId())
&& !new OtherUserAccess().canRead(user, table, id).orElse(false))))
{
throw (new QException(new PeopleService().displayName(user) + " can't open this record."));
}
}
/*******************************************************************************
** Bounded text validation before any write or notification.
*******************************************************************************/
public static void text(String value, int max, boolean required) throws QException
{
if((required && (value == null || value.isBlank())) || (value != null && value.length() > max))
{
throw (new QException("Text must contain " + (required ? "1" : "0") + " to " + max + " characters."));
}
}
}
@@ -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("<section><h3>Open access requests</h3><p>").append(count(CollaborationTables.ACCESS_REQUEST,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
html.append("<section><h3>Open tasks</h3><p>").append(count(CollaborationTables.TASK,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
html.append("<section><h3>Overdue tasks</h3><p>").append(count(CollaborationTables.TASK,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN").withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now))).append("</p></section>");
html.append("<section><h3>Shares this week</h3><p>").append(count(CollaborationTables.SHARE,
new QQueryFilter().withCriteria("sentAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
.withCriteria("sentAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))).append("</p></section>");
html.append("<section><h3>Comments this week</h3><p>").append(count(CollaborationTables.COMMENT, new QQueryFilter()
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
.withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("</p></section>");
@@ -0,0 +1,156 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.assignment;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.WorkSupport;
import com.kingsrook.qbits.collaboration.activity.ActivityWriter;
import com.kingsrook.qbits.collaboration.follow.FollowService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.Notifications;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Ownership storage for hosts without native RecordOwnership columns.
*******************************************************************************/
public class AssignmentService
{
/*******************************************************************************
** Full replacement of both roles; null clears a role.
*******************************************************************************/
public QRecord assign(String table, Serializable recordId, String ownerUserId, String assigneeUserId) throws QException
{
ParentRecordAccess.requireFeature(table, recordId, "assign");
String id = FollowService.canonicalId(table, recordId);
WorkSupport.requireReader(ownerUserId, table, id);
WorkSupport.requireReader(assigneeUserId, table, id);
synchronized(CollaborationRecords.config())
{
Optional<QRecord> old = get(table, id);
QRecord change = new QRecord().withValue("ownerUserId", ownerUserId).withValue("assigneeUserId", assigneeUserId);
QRecord row;
if(old.isEmpty())
{
row = CollaborationRecords.insert(CollaborationTables.RECORD_ASSIGNMENT, change.withValue("tableName", table).withValue("recordId", id).withValue("parentDeleted", false));
}
else
{
CollaborationRecords.update(CollaborationTables.RECORD_ASSIGNMENT, change.withValue("id", old.get().getValue("id")));
row = get(table, id).orElseThrow();
}
ActivityWriter.write(table, id, "ASSIGNED", "Record assignment changed", CollaborationTables.RECORD_ASSIGNMENT, row.getValueString("id"));
if(assigneeUserId != null && (old.isEmpty() || !Objects.equals(old.get().getValueString("assigneeUserId"), assigneeUserId)))
{
new FollowService().ensureFollower(assigneeUserId, table, id, "ASSIGNED");
if(!assigneeUserId.equals(PersonalRecords.currentUserId()))
{
Notifications.send(new CollaborationNotification(assigneeUserId, "A record was assigned to you", null,
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)));
}
}
return (row);
}
}
/*******************************************************************************
** Parent readers can see assignment independently of the admin-only table nav.
*******************************************************************************/
public Optional<QRecord> get(String table, Serializable recordId) throws QException
{
return (CollaborationRecords.query(CollaborationTables.RECORD_ASSIGNMENT, CollaborationRecords.parent(table, FollowService.canonicalId(table, recordId))).stream().findFirst());
}
/*******************************************************************************
** Counts cover all visible matches; limit bounds the rows returned per table.
*******************************************************************************/
public Map<String, Group> myRecords(String table, Integer limit) throws QException
{
int size = limit == null ? 20 : limit;
if(size < 1 || size > 100)
{
throw (new QException("Limit must be between 1 and 100."));
}
Map<String, List<QRecord>> groups = new LinkedHashMap<>();
for(QRecord row : mineRows(table, false))
{
groups.computeIfAbsent(row.getValueString("tableName"), key -> new ArrayList<>()).add(row);
}
Map<String, Group> result = new LinkedHashMap<>();
groups.forEach((name, rows) -> result.put(name, new Group((long) rows.size(), rows.stream().limit(size).toList())));
return (result);
}
/*******************************************************************************
** Fresh parent checks protect both personal rows and counts after access changes.
*******************************************************************************/
public List<QRecord> mineRows(String table, Boolean assignedOnly) throws QException
{
String user = PersonalRecords.currentUserId();
QQueryFilter roles = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("assigneeUserId", QCriteriaOperator.EQUALS, user);
if(!Boolean.TRUE.equals(assignedOnly))
{
roles.withCriteria("ownerUserId", QCriteriaOperator.EQUALS, user);
}
QQueryFilter filter = new QQueryFilter().withSubFilter(roles).withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false)
.withOrderBy(new QFilterOrderBy("tableName")).withOrderBy(new QFilterOrderBy("id"));
if(table != null)
{
filter.withCriteria("tableName", QCriteriaOperator.EQUALS, table);
}
List<QRecord> visible = new ArrayList<>();
for(QRecord row : CollaborationRecords.query(CollaborationTables.RECORD_ASSIGNMENT, filter))
{
try
{
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
visible.add(row);
}
catch(QNotFoundException expected)
{
// Revoked access must not contribute even to the count.
}
}
return (visible);
}
/*******************************************************************************
** Total and first page for one host table.
*******************************************************************************/
public record Group(Long count, List<QRecord> records)
{
}
}
@@ -61,6 +61,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)
{
@@ -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<QPossibleValueSource> possibleValueSources()
{
return (List.of(
QPossibleValueSource.newForTable(SHARE), QPossibleValueSource.newForTable(ACCESS_REQUEST),
QPossibleValueSource.newForTable(TASK), QPossibleValueSource.newForTable(RECORD_ASSIGNMENT),
enumSource("collaboration_access_request_status", "OPEN", "GRANTED", "DECLINED", "WITHDRAWN"),
enumSource("collaboration_task_status", "OPEN", "DONE", "CANCELLED"),
QPossibleValueSource.newForTable(TAG), QPossibleValueSource.newForTable(RECORD_TAG),
QPossibleValueSource.newForTable(COMMENT), QPossibleValueSource.newForTable(COMMENT_REVISION), QPossibleValueSource.newForTable(MENTION), QPossibleValueSource.newForTable(FOLLOW), QPossibleValueSource.newForTable(ACTIVITY), QPossibleValueSource.newForTable(FOLLOW_DIGEST),
enumSource(TARGET_TYPE, "TABLE", "APP", "RECORD", "PROCESS", "REPORT", "SAVED_VIEW"),
@@ -165,6 +173,7 @@ public final class CollaborationTables
List<QTableMetaData> tables = new ArrayList<>(List.of(settings, people, favorites, recents, attachment(config)));
tables.addAll(conversationTables(config));
tables.addAll(tagTables(config));
tables.addAll(workTables(config));
for(QTableMetaData table : tables)
{
table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates"));
@@ -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<QTableMetaData> workTables(CollaborationQBitConfig config)
{
QTableMetaData shares = referenced(config, SHARE, "Shares", "share")
.withField(string("fromUserId", "From", 255).withIsRequired(true))
.withField(string("toUserId", "To", 255).withIsRequired(true))
.withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000))
.withField(new QFieldMetaData("recipientCouldRead", QFieldType.BOOLEAN))
.withField(new QFieldMetaData("sentAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(new QFieldMetaData("openedAt", QFieldType.DATE_TIME));
QTableMetaData requests = base(config, ACCESS_REQUEST, "Access Requests", "lock_open")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.sharing.AccessRequestSecurity.class)))
.withRecordSecurityLock(CollaborationSecurity.ownerLock("requesterUserId"))
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(com.kingsrook.qbits.collaboration.sharing.AccessRequestSecurity.class))
.withRecordLabelFormatAndFields("Access request %s", "id")
.withField(string("tableName", "Table", 128).withIsRequired(true))
.withField(string("recordId", "Record ID", 64).withIsRequired(true))
.withField(string("requesterUserId", "Requester", 255).withIsRequired(true))
.withField(new QFieldMetaData("shareId", QFieldType.INTEGER))
.withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000))
.withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_access_request_status"))
.withField(string("handledByUserId", "Handled By", 255))
.withField(new QFieldMetaData("handledAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("note", QFieldType.TEXT).withMaxLength(2000));
QTableMetaData tasks = referenced(config, TASK, "Tasks", "task_alt")
.withRecordLabelFormatAndFields("%s", "title").withSearchFields(List.of("title"))
.withField(string("title", "Title", 200).withIsRequired(true))
.withField(new QFieldMetaData("notes", QFieldType.TEXT))
.withField(string("assigneeUserId", "Assignee", 255))
.withField(new QFieldMetaData("dueAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("remindAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("remindedAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("overdueNotifiedAt", QFieldType.DATE_TIME))
.withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_task_status"))
.withField(new QFieldMetaData("completedAt", QFieldType.DATE_TIME))
.withField(string("completedByUserId", "Completed By", 255))
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true));
QTableMetaData assignments = referenced(config, RECORD_ASSIGNMENT, "Record Owners", "assignment_ind")
.withField(string("ownerUserId", "Owner", 255))
.withField(string("assigneeUserId", "Assignee", 255))
.withUniqueKey(new UniqueKey("tableName", "recordId"));
List<QTableMetaData> tables = List.of(shares, requests, tasks, assignments);
for(QTableMetaData table : tables)
{
table.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withSection(new QFieldSection("details", new QIcon("info"), Tier.T1,
table.getFields().keySet().stream().filter(name -> !List.of("createDate", "modifyDate", "parentDeleted").contains(name)
&& (table.getName().equals(ACCESS_REQUEST) || !List.of("tableName", "recordId").contains(name))).toList()).withLabel("Details"));
table.getFields().values().forEach(field -> field.setIsEditable(false));
}
return (tables);
}
}
@@ -0,0 +1,104 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.mywork;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qbits.collaboration.tasks.TasksService;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Caller-scoped work summary; parent-invisible rows never contribute counts.
*******************************************************************************/
public class MyWorkService
{
/*******************************************************************************
** Open task count and first ten, assigned record counts, and unread mentions.
*******************************************************************************/
public Summary summary() throws QException
{
List<QRecord> tasks = new TasksService().mineRows("OPEN");
Map<String, Long> assignments = new LinkedHashMap<>();
for(QRecord row : new AssignmentService().mineRows(null, true))
{
assignments.merge(row.getValueString("tableName"), 1L, Long::sum);
}
return (new Summary((long) tasks.size(), tasks.stream().limit(10).toList(), assignments, (long) unread().size()));
}
/*******************************************************************************
** Only delivered, unread mentions of currently visible records are actionable.
*******************************************************************************/
private List<QRecord> unread() throws QException
{
List<QRecord> result = new ArrayList<>();
for(QRecord row : CollaborationRecords.query(CollaborationTables.MENTION, new QQueryFilter()
.withCriteria("mentionedUserId", QCriteriaOperator.EQUALS, PersonalRecords.currentUserId())
.withCriteria("notifiedAt", QCriteriaOperator.IS_NOT_BLANK).withCriteria("readAt", QCriteriaOperator.IS_BLANK)
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false)))
{
try
{
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
result.add(row);
}
catch(QNotFoundException expected)
{
// Do not reveal the existence of mentions on revoked parents.
}
}
return (result);
}
/*******************************************************************************
** Null selects all visible unread mentions; supplied IDs remain owner-scoped.
*******************************************************************************/
public void markMentionsRead(List<Integer> mentionIds) throws QException
{
for(QRecord row : unread())
{
if(mentionIds == null || mentionIds.contains(row.getValueInteger("id")))
{
CollaborationRecords.update(CollaborationTables.MENTION, new QRecord().withValue("id", row.getValue("id")).withValue("readAt", Instant.now()));
}
}
}
/*******************************************************************************
** Stable service contract for the subsequent UI API PR.
*******************************************************************************/
public record Summary(Long openTaskCount, List<QRecord> tasks, Map<String, Long> assignedRecordsByTable, Long unreadMentionCount)
{
}
}
@@ -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<QException> receipt) throws QException
{
send(notification, receipt, true);
}
/*******************************************************************************
** Only for access-request notices containing no parent data or sender message.
*******************************************************************************/
public static void sendAccessNotice(CollaborationNotification notification) throws QException
{
send(notification, null, false);
}
/*******************************************************************************
** Both safe notices and parent-data delivery respect transaction completion.
*******************************************************************************/
private static void send(CollaborationNotification notification, UnsafeVoidVoidMethod<QException> receipt, boolean checkParent) throws QException
{
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
if(QContext.getQBackendTransaction() != null)
@@ -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<QException> receipt) throws QException
private static void deliver(CollaborationNotification notification, UnsafeVoidVoidMethod<QException> 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));
}
}
@@ -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."));
@@ -0,0 +1,70 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.sharing;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** Requester/admin visibility is independent of parent visibility and existence.
*******************************************************************************/
public class AccessRequestSecurity implements CustomPermissionChecker, TableCustomizerInterface
{
/*******************************************************************************
** Service-only writes prevent forging requester IDs and outcomes.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
new CollaborationSecurity().checkPermissionsThrowing(input, metadata);
if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput)
{
throw (new QPermissionDeniedException("Use the access request service actions."));
}
}
/*******************************************************************************
** Only metadata labels are resolved; never read the referenced parent.
*******************************************************************************/
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
{
for(QRecord row : records)
{
var table = QContext.getQInstance().getTable(row.getValueString("tableName"));
row.setDisplayValue("tableName", table == null ? row.getValueString("tableName") : table.getLabel());
}
return (records);
}
}
@@ -0,0 +1,237 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.sharing;
import java.io.Serializable;
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.TargetResolver;
import com.kingsrook.qbits.collaboration.WorkSupport;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.Notifications;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Requests never inspect the parent. Their public result reveals no existence data.
*******************************************************************************/
public class AccessRequestService
{
private static final QLogger LOG = QLogger.getLogger(AccessRequestService.class);
private static final AtomicBoolean DIRECTORY_WARNING = new AtomicBoolean();
/*******************************************************************************
** Persist once per open natural key; repeated submissions return the same row.
*******************************************************************************/
public QRecord request(String table, Serializable recordId, String message, Integer shareId) throws QException
{
String user = PersonalRecords.currentUserId();
WorkSupport.text(table, 128, true);
WorkSupport.text(recordId == null ? null : recordId.toString(), 64, true);
WorkSupport.text(message, 2000, false);
String id = TargetResolver.recordId("RECORD", table, recordId);
synchronized(CollaborationRecords.config())
{
QQueryFilter filter = new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
.withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("requesterUserId", QCriteriaOperator.EQUALS, user)
.withCriteria("status", QCriteriaOperator.EQUALS, "OPEN");
List<QRecord> existing = CollaborationRecords.query(CollaborationTables.ACCESS_REQUEST, filter);
if(!existing.isEmpty())
{
return (existing.getFirst());
}
if(shareId != null)
{
QRecord share = WorkSupport.row(CollaborationTables.SHARE, shareId);
if(!user.equals(share.getValueString("toUserId")) || !table.equals(share.getValueString("tableName")) || !id.equals(share.getValueString("recordId")))
{
throw (new QNotFoundException("Not found"));
}
}
QRecord row;
try
{
row = CollaborationRecords.insert(CollaborationTables.ACCESS_REQUEST, new QRecord().withValue("tableName", table).withValue("recordId", id)
.withValue("requesterUserId", user).withValue("message", message).withValue("shareId", shareId).withValue("status", "OPEN"));
}
catch(QException e)
{
existing = CollaborationRecords.query(CollaborationTables.ACCESS_REQUEST, filter);
if(existing.isEmpty())
{
throw (e);
}
return (existing.getFirst());
}
notifyApprovers(row);
return (row);
}
}
/*******************************************************************************
** Resolve table sharing policy without touching any parent row.
*******************************************************************************/
public String approverPermission(String table) throws QException
{
for(QRecord setting : CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter()
.withCriteria("scopeType", QCriteriaOperator.EQUALS, "TABLE").withCriteria("scopeName", QCriteriaOperator.EQUALS, table)
.withCriteria("feature", QCriteriaOperator.EQUALS, "SHARING")))
{
String json = setting.getValueString("settings");
if(json != null && !json.isBlank())
{
try
{
Object permission = JsonUtils.toObject(json, Map.class).get("accessApproverPermission");
if(permission != null && !permission.toString().isBlank())
{
return (permission.toString());
}
}
catch(Exception e)
{
throw (new QException("Invalid SHARING settings JSON", e));
}
}
}
return (CollaborationPermissions.ADMIN);
}
/*******************************************************************************
** Owner lookup uses only the assignment directory, never a parent existence check.
*******************************************************************************/
private void notifyApprovers(QRecord row) throws QException
{
Set<String> users = new LinkedHashSet<>();
var reference = CollaborationRecords.config().getPermissionHolderDirectory();
if(reference == null)
{
if(DIRECTORY_WARNING.compareAndSet(false, true))
{
LOG.warn("Access approvers cannot be resolved without PermissionHolderDirectory; only configured record owners will be notified.");
}
}
else
{
PermissionHolderDirectory directory = QCodeLoader.getAdHoc(PermissionHolderDirectory.class, reference);
users.addAll(directory.userIdsWithPermission(CollaborationPermissions.ADMIN));
users.addAll(directory.userIdsWithPermission(approverPermission(row.getValueString("tableName"))));
}
for(QRecord assignment : CollaborationRecords.query(CollaborationTables.RECORD_ASSIGNMENT,
CollaborationRecords.parent(row.getValueString("tableName"), row.getValueString("recordId"))))
{
if(assignment.getValueString("ownerUserId") != null)
{
users.add(assignment.getValueString("ownerUserId"));
}
}
for(String user : users)
{
notice(user, "Record access requested", row.getValueInteger("id"));
}
}
/*******************************************************************************
** Requesters can only withdraw their own still-open request.
*******************************************************************************/
public void withdraw(Integer requestId) throws QException
{
QRecord row = WorkSupport.row(CollaborationTables.ACCESS_REQUEST, requestId);
if(!PersonalRecords.currentUserId().equals(row.getValueString("requesterUserId")))
{
throw (new QNotFoundException("Not found"));
}
requireOpen(row);
CollaborationRecords.update(CollaborationTables.ACCESS_REQUEST, new QRecord().withValue("id", requestId).withValue("status", "WITHDRAWN"));
}
/*******************************************************************************
** Approver permissions authorize only recording an outcome, never an access grant.
*******************************************************************************/
public QRecord requireApprover(Integer requestId) throws QException
{
PersonalRecords.currentUserId();
QRecord row = WorkSupport.row(CollaborationTables.ACCESS_REQUEST, requestId);
if(!QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN)
&& !QContext.getQSession().hasPermission(approverPermission(row.getValueString("tableName"))))
{
throw (new QPermissionDeniedException("This needs the access approver permission."));
}
requireOpen(row);
return (row);
}
/*******************************************************************************
** The host identity system is responsible for any actual permission change.
*******************************************************************************/
public void handle(Integer requestId, String status, String note) throws QException
{
WorkSupport.text(note, 2000, false);
if(!List.of("GRANTED", "DECLINED").contains(status))
{
throw (new QException("Choose GRANTED or DECLINED."));
}
synchronized(CollaborationRecords.config())
{
QRecord row = requireApprover(requestId);
CollaborationRecords.update(CollaborationTables.ACCESS_REQUEST, new QRecord().withValue("id", requestId).withValue("status", status)
.withValue("note", note).withValue("handledAt", Instant.now()).withValue("handledByUserId", PersonalRecords.currentUserId()));
notice(row.getValueString("requesterUserId"), "Access request " + status.toLowerCase(java.util.Locale.ROOT), requestId);
}
}
/*******************************************************************************
** Terminal outcomes cannot be overwritten accidentally.
*******************************************************************************/
private void requireOpen(QRecord row) throws QException
{
if(!"OPEN".equals(row.getValueString("status")))
{
throw (new QException("This access request is no longer open."));
}
}
/*******************************************************************************
** Notices contain no record label, request message or approver note.
*******************************************************************************/
private void notice(String user, String title, Integer id) throws QException
{
Notifications.sendAccessNotice(new CollaborationNotification(user, title, "Review the access request.",
CollaborationNotification.Category.SHARE, null, null, "/app/collaboration_access_request/" + id));
}
}
@@ -0,0 +1,36 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.sharing;
import java.util.Set;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Optional host directory; membership resolution never grants record access.
*******************************************************************************/
public interface PermissionHolderDirectory
{
/*******************************************************************************
** Return stable user IDs holding this permission.
*******************************************************************************/
Set<String> userIdsWithPermission(String permission) throws QException;
}
@@ -0,0 +1,147 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.sharing;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.TargetResolver;
import com.kingsrook.qbits.collaboration.WorkSupport;
import com.kingsrook.qbits.collaboration.activity.ActivityWriter;
import com.kingsrook.qbits.collaboration.follow.FollowService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.Notifications;
import com.kingsrook.qbits.collaboration.people.PeopleService;
import com.kingsrook.qbits.collaboration.security.OtherUserAccess;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Shares communicate intent; they never change permissions.
*******************************************************************************/
public class SharingService
{
/*******************************************************************************
** Canonical link and a fresh label resolved as the caller.
*******************************************************************************/
public Link link(String table, Serializable recordId) throws QException
{
ParentRecordAccess.requireVisibleParent(table, recordId);
var target = TargetResolver.require("RECORD", table, recordId);
return (new Link(TargetResolver.recordHref(table, target.recordId()), target.label()));
}
/*******************************************************************************
** Unknown recipient access is represented by null, never converted to true.
*******************************************************************************/
public List<Recipient> preview(String table, Serializable recordId, List<String> toUserIds) throws QException
{
new PeopleService().touch();
ParentRecordAccess.requireFeature(table, recordId, "share");
if(toUserIds == null || toUserIds.isEmpty() || toUserIds.size() > 25 || toUserIds.stream().anyMatch(user -> user == null || user.isBlank()))
{
throw (new QException("Choose 1 to 25 recipients."));
}
List<Recipient> recipients = new ArrayList<>();
for(String user : toUserIds.stream().distinct().toList())
{
Optional<Boolean> access = user.equals(PersonalRecords.currentUserId()) ? Optional.of(true) : new OtherUserAccess().canRead(user, table, recordId);
recipients.add(new Recipient(user, new PeopleService().displayName(user), access.orElse(null)));
}
return (recipients);
}
/*******************************************************************************
** Nonreaders get neither the record label nor the sender's untrusted message.
*******************************************************************************/
public List<QRecord> share(String table, Serializable recordId, List<String> toUserIds, String message) throws QException
{
WorkSupport.text(message, 2000, false);
List<Recipient> recipients = preview(table, recordId, toUserIds);
String id = FollowService.canonicalId(table, recordId);
Link link = link(table, id);
String from = PersonalRecords.currentUserId();
String name = new PeopleService().displayName(from);
List<QRecord> result = new ArrayList<>();
for(Recipient recipient : recipients)
{
QRecord row = CollaborationRecords.insert(CollaborationTables.SHARE, new QRecord().withValue("tableName", table).withValue("recordId", id)
.withValue("fromUserId", from).withValue("toUserId", recipient.userId()).withValue("message", message)
.withValue("recipientCouldRead", recipient.canRead()).withValue("sentAt", Instant.now()).withValue("parentDeleted", false));
if(Boolean.TRUE.equals(recipient.canRead()))
{
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared " + link.label() + " with you", message,
CollaborationNotification.Category.SHARE, table, id, link.url()));
new FollowService().ensureFollower(recipient.userId(), table, id, "SHARED");
}
else
{
Notifications.sendAccessNotice(new CollaborationNotification(recipient.userId(), name + " wants to share a record in "
+ QContext.getQInstance().getTable(table).getLabel() + " with you", "Request access to open this record.",
CollaborationNotification.Category.SHARE, null, null, "/app/collaboration_access_request/create?tableName="
+ TargetResolver.encode(table) + "&recordId=" + TargetResolver.encode(id) + "&shareId=" + row.getValueString("id")));
}
ActivityWriter.write(table, id, "SHARED", "Record shared", CollaborationTables.SHARE, row.getValueString("id"));
result.add(row);
}
return (result);
}
/*******************************************************************************
** The intended recipient may acknowledge even a share of an invisible parent.
*******************************************************************************/
public void markOpened(Integer shareId) throws QException
{
String user = PersonalRecords.currentUserId();
QRecord row = WorkSupport.row(CollaborationTables.SHARE, shareId);
if(!user.equals(row.getValueString("toUserId")))
{
throw (new QNotFoundException("Not found"));
}
if(row.getValueInstant("openedAt") == null)
{
CollaborationRecords.update(CollaborationTables.SHARE, new QRecord().withValue("id", shareId).withValue("openedAt", Instant.now()));
}
}
/*******************************************************************************
** Link payload for the later UI API adapter.
*******************************************************************************/
public record Link(String url, String label)
{
}
/*******************************************************************************
** A nullable Boolean distinguishes unavailable access knowledge from denial.
*******************************************************************************/
public record Recipient(String userId, String displayName, Boolean canRead)
{
}
}
@@ -0,0 +1,99 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.tasks;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Hidden minute-by-minute task reminder job. Runs with its caller's session, which must be a
** scheduler system session or collaboration admin; never elevates a user.
*******************************************************************************/
public class SendTaskRemindersProcess implements BackendStep, CustomPermissionChecker
{
public static final String NAME = "collaborationSendTaskReminders";
/*******************************************************************************
** Always register the backend-only process; scheduling is opt-in.
*******************************************************************************/
public static QProcessMetaData metadata(CollaborationQBitConfig config)
{
QProcessMetaData process = new QProcessMetaData().withName(NAME).withLabel("Send task reminders").withIsHidden(true).withIcon(new QIcon("history"))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withPermissionBaseName("collaboration").withCustomPermissionChecker(new QCodeReference(SendTaskRemindersProcess.class)))
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(SendTaskRemindersProcess.class)));
if(config.getSchedulerName() != null)
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(60).withInitialDelaySeconds(60));
}
return (process);
}
/*******************************************************************************
** Hidden is a presentation setting, so explicitly guard execution as well.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(CollaborationPermissions.ADMIN)))
{
throw (new QPermissionDeniedException("This needs the collaboration.admin permission."));
}
}
/*******************************************************************************
** Deliver due reminders using fresh recipient permissions.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
checkPermissionsThrowing(input, null);
new TaskReminders().sendDue();
}
}
@@ -0,0 +1,92 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.tasks;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.Notifications;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** One scheduler per installation; receipts follow successful delivery only.
*******************************************************************************/
public class TaskReminders
{
private final Clock clock;
/*******************************************************************************
** Production clock.
*******************************************************************************/
public TaskReminders()
{
this(Clock.systemUTC());
}
/*******************************************************************************
** Clock shared by due selection and successful-delivery receipts.
*******************************************************************************/
public TaskReminders(Clock clock)
{
this.clock = clock;
}
/*******************************************************************************
** No open transaction may defer receipts past the scheduler's serialization lock.
*******************************************************************************/
public void sendDue() throws QException
{
new SendTaskRemindersProcess().checkPermissionsThrowing(null, null);
if(QContext.getQBackendTransaction() != null)
{
throw (new QException("Send task reminders outside a transaction."));
}
synchronized(CollaborationRecords.config())
{
Instant now = clock.instant();
for(String dueField : List.of("remindAt", "dueAt"))
{
String receipt = dueField.equals("remindAt") ? "remindedAt" : "overdueNotifiedAt";
QQueryFilter filter = new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN")
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false).withCriteria("assigneeUserId", QCriteriaOperator.IS_NOT_BLANK)
.withCriteria(receipt, QCriteriaOperator.IS_BLANK).withCriteria(dueField, QCriteriaOperator.IS_NOT_BLANK)
.withCriteria(dueField, dueField.equals("dueAt") ? QCriteriaOperator.LESS_THAN : QCriteriaOperator.LESS_THAN_OR_EQUALS, now);
for(QRecord row : CollaborationRecords.query(CollaborationTables.TASK, filter))
{
String table = row.getValueString("tableName");
String id = row.getValueString("recordId");
Notifications.send(new CollaborationNotification(row.getValueString("assigneeUserId"),
(dueField.equals("remindAt") ? "Task reminder: " : "Task overdue: ") + row.getValueString("title"), row.getValueString("notes"),
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)),
() -> CollaborationRecords.update(CollaborationTables.TASK, new QRecord().withValue("id", row.getValue("id")).withValue(receipt, now)));
}
}
}
}
}
@@ -0,0 +1,319 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.tasks;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.WorkSupport;
import com.kingsrook.qbits.collaboration.activity.ActivityWriter;
import com.kingsrook.qbits.collaboration.follow.FollowChangeBatcher;
import com.kingsrook.qbits.collaboration.follow.FollowService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.Notifications;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Record tasks, authorized as the caller and timed through an injectable clock.
*******************************************************************************/
public class TasksService
{
private final Clock clock;
private static final Comparator<QRecord> ORDER = Comparator.comparing((QRecord row) -> row.getValueInstant("dueAt"), Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(row -> row.getValueInteger("id"));
/*******************************************************************************
** Production time source.
*******************************************************************************/
public TasksService()
{
this(Clock.systemUTC());
}
/*******************************************************************************
** Deterministic reminder and completion times.
*******************************************************************************/
public TasksService(Clock clock)
{
this.clock = clock;
}
/*******************************************************************************
** An omitted assignee defaults to the creator.
*******************************************************************************/
public QRecord create(String table, Serializable recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt) throws QException
{
ParentRecordAccess.requireFeature(table, recordId, "task");
String id = FollowService.canonicalId(table, recordId);
String user = PersonalRecords.currentUserId();
String assignee = assigneeUserId == null ? user : assigneeUserId;
WorkSupport.text(title, 200, true);
WorkSupport.requireReader(assignee, table, id);
QRecord row = CollaborationRecords.insert(CollaborationTables.TASK, new QRecord().withValue("tableName", table).withValue("recordId", id)
.withValue("title", title).withValue("notes", notes).withValue("assigneeUserId", assignee).withValue("dueAt", dueAt)
.withValue("remindAt", reminder(dueAt, remindAt)).withValue("status", "OPEN").withValue("createdByUserId", user).withValue("parentDeleted", false));
activity(row, "TASK_CREATED", "Task created");
assigned(row);
return (row);
}
/*******************************************************************************
** Full editable-field replacement; null dates clear them, null assignee unassigns.
*******************************************************************************/
public QRecord update(Integer taskId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt) throws QException
{
QRecord old = requireManager(taskId);
WorkSupport.text(title, 200, true);
WorkSupport.requireReader(assigneeUserId, old.getValueString("tableName"), old.getValueString("recordId"));
QRecord change = new QRecord().withValue("id", taskId).withValue("title", title).withValue("notes", notes)
.withValue("assigneeUserId", assigneeUserId).withValue("dueAt", dueAt).withValue("remindAt", reminder(dueAt, remindAt));
if(!Objects.equals(old.getValueInstant("remindAt"), change.getValueInstant("remindAt")) || !Objects.equals(old.getValueString("assigneeUserId"), assigneeUserId))
{
change.withValue("remindedAt", null);
}
if(!Objects.equals(old.getValueInstant("dueAt"), dueAt) || !Objects.equals(old.getValueString("assigneeUserId"), assigneeUserId))
{
change.withValue("overdueNotifiedAt", null);
}
CollaborationRecords.update(CollaborationTables.TASK, change);
QRecord row = WorkSupport.visible(CollaborationTables.TASK, taskId);
if(!Objects.equals(old.getValueString("assigneeUserId"), assigneeUserId))
{
assigned(row);
}
return (row);
}
/*******************************************************************************
** Creator, assignee and admins still need current parent and task permission.
*******************************************************************************/
public QRecord requireManager(Integer taskId) throws QException
{
QRecord row = WorkSupport.visible(CollaborationTables.TASK, taskId);
String user = PersonalRecords.currentUserId();
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "task");
if(!user.equals(row.getValueString("createdByUserId")) && !user.equals(row.getValueString("assigneeUserId"))
&& !QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
{
throw (new QPermissionDeniedException("Only the task creator, assignee or collaboration.admin can change this task."));
}
return (row);
}
/*******************************************************************************
** Idempotent completion emits one activity for the transition.
*******************************************************************************/
public QRecord complete(Integer taskId) throws QException
{
return (transition(taskId, "DONE"));
}
/*******************************************************************************
** Reopening clears completion fields, retaining delivery history.
*******************************************************************************/
public QRecord reopen(Integer taskId) throws QException
{
return (transition(taskId, "OPEN"));
}
/*******************************************************************************
** Cancellation stops scheduled notifications.
*******************************************************************************/
public QRecord cancel(Integer taskId) throws QException
{
return (transition(taskId, "CANCELLED"));
}
/*******************************************************************************
** Serialize transitions within the installation, like existing follow services.
*******************************************************************************/
private QRecord transition(Integer taskId, String status) throws QException
{
synchronized(CollaborationRecords.config())
{
QRecord row = requireManager(taskId);
if(!status.equals(row.getValueString("status")))
{
CollaborationRecords.update(CollaborationTables.TASK, new QRecord().withValue("id", taskId).withValue("status", status)
.withValue("completedAt", status.equals("DONE") ? clock.instant() : null)
.withValue("completedByUserId", status.equals("DONE") ? PersonalRecords.currentUserId() : null));
if(status.equals("DONE"))
{
activity(row, "TASK_DONE", "Task completed");
}
}
return (WorkSupport.visible(CollaborationTables.TASK, taskId));
}
}
/*******************************************************************************
** Parent readers can list open tasks, optionally including terminal tasks.
*******************************************************************************/
public List<QRecord> list(String table, Serializable recordId, Boolean includeDone) throws QException
{
QQueryFilter filter = CollaborationRecords.parent(table, FollowService.canonicalId(table, recordId));
if(!Boolean.TRUE.equals(includeDone))
{
filter.withCriteria("status", QCriteriaOperator.EQUALS, "OPEN");
}
return (CollaborationRecords.query(CollaborationTables.TASK, filter).stream().sorted(ORDER).toList());
}
/*******************************************************************************
** Due-date keyset paging, null dates last, with stable ID tie-breaking.
*******************************************************************************/
public Page mine(String status, Integer limit, String cursor) throws QException
{
int size = limit == null ? 20 : limit;
if(size < 1 || size > 100)
{
throw (new QException("Limit must be between 1 and 100."));
}
List<QRecord> rows = mineRows(status);
if(cursor != null)
{
QRecord after = decode(cursor);
rows = rows.stream().filter(row -> ORDER.compare(row, after) > 0).toList();
}
List<QRecord> page = rows.stream().limit(size).toList();
return (new Page(page, rows.size() > size ? encode(page.getLast()) : null));
}
/*******************************************************************************
** Also supplies exact visible counts for My work; no aggregate lock leakage.
*******************************************************************************/
public List<QRecord> mineRows(String status) throws QException
{
String selected = status == null ? "OPEN" : status;
if(!List.of("OPEN", "DONE", "CANCELLED").contains(selected))
{
throw (new QException("Unknown task status."));
}
List<QRecord> visible = new ArrayList<>();
for(QRecord row : CollaborationRecords.query(CollaborationTables.TASK, new QQueryFilter()
.withCriteria("assigneeUserId", QCriteriaOperator.EQUALS, PersonalRecords.currentUserId()).withCriteria("status", QCriteriaOperator.EQUALS, selected)
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false)))
{
try
{
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
visible.add(row);
}
catch(QNotFoundException expected)
{
// Removed access removes the task from personal results.
}
}
return (visible.stream().sorted(ORDER).toList());
}
/*******************************************************************************
** The default never schedules a reminder in the past for a future due date.
*******************************************************************************/
private Instant reminder(Instant due, Instant supplied)
{
return (supplied != null ? supplied : due == null ? null : due.minusSeconds(86400).isAfter(clock.instant()) ? due.minusSeconds(86400) : due);
}
/*******************************************************************************
** Assignees become followers; self-assignment avoids an immediate notification.
*******************************************************************************/
private void assigned(QRecord row) throws QException
{
String user = row.getValueString("assigneeUserId");
if(user != null)
{
String table = row.getValueString("tableName");
String id = row.getValueString("recordId");
new FollowService().ensureFollower(user, table, id, "ASSIGNED");
if(!user.equals(PersonalRecords.currentUserId()))
{
Notifications.send(new CollaborationNotification(user, "Task assigned: " + row.getValueString("title"), row.getValueString("notes"),
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)));
}
}
}
/*******************************************************************************
** Task events also enter the existing notify-on-change follower digest.
*******************************************************************************/
private void activity(QRecord row, String type, String summary) throws QException
{
String table = row.getValueString("tableName");
String id = row.getValueString("recordId");
ActivityWriter.write(table, id, type, summary, CollaborationTables.TASK, row.getValueString("id"));
new FollowChangeBatcher(clock).recordChange(table, id, PersonalRecords.currentUserId(), Set.of(summary));
}
/*******************************************************************************
** Versioned cursor includes only the sort tuple.
*******************************************************************************/
private String encode(QRecord row)
{
return (Base64.getUrlEncoder().withoutPadding().encodeToString(("1\n" + row.getValueInstant("dueAt") + "\n" + row.getValueInteger("id")).getBytes(StandardCharsets.UTF_8)));
}
/*******************************************************************************
** Reject malformed cursor input before interpreting the sort tuple.
*******************************************************************************/
private QRecord decode(String cursor) throws QException
{
try
{
String[] parts = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8).split("\n");
if(parts.length != 3 || !parts[0].equals("1"))
{
throw (new IllegalArgumentException("Invalid cursor"));
}
return (new QRecord().withValue("dueAt", parts[1].equals("null") ? null : Instant.parse(parts[1])).withValue("id", Integer.valueOf(parts[2])));
}
catch(RuntimeException e)
{
throw (new QException("Invalid cursor", e));
}
}
/*******************************************************************************
** Service page consumed by the future endpoint adapter.
*******************************************************************************/
public record Page(List<QRecord> tasks, String nextCursor)
{
}
}
@@ -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);
}
@@ -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);
@@ -0,0 +1,626 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.io.Serializable;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
import com.kingsrook.qbits.collaboration.comments.CommentsService;
import com.kingsrook.qbits.collaboration.follow.FollowService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.mywork.MyWorkService;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier;
import com.kingsrook.qbits.collaboration.notify.RecordingNotifier;
import com.kingsrook.qbits.collaboration.security.UserSessionSupplier;
import com.kingsrook.qbits.collaboration.sharing.AccessRequestService;
import com.kingsrook.qbits.collaboration.sharing.PermissionHolderDirectory;
import com.kingsrook.qbits.collaboration.sharing.SharingService;
import com.kingsrook.qbits.collaboration.tasks.SendTaskRemindersProcess;
import com.kingsrook.qbits.collaboration.tasks.TaskReminders;
import com.kingsrook.qbits.collaboration.tasks.TasksService;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Service and streamed-process acceptance with real parent locks and notifications.
*******************************************************************************/
public class SharingWorkTest extends PersonalServiceFixture
{
private static RecordingNotifier recorder;
private static final Map<String, QSession> sessions = new HashMap<>();
private static final Instant NOW = Instant.parse("2026-10-13T12:00:00Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private final SharingService sharing = new SharingService();
private final AccessRequestService access = new AccessRequestService();
private final TasksService tasks = new TasksService(CLOCK);
private final AssignmentService assignments = new AssignmentService();
/*******************************************************************************
** Configure.
*******************************************************************************/
@BeforeEach
void configure() throws Exception
{
recorder = new RecordingNotifier();
sessions.clear();
sessions.put("alice", session("alice", "a"));
sessions.put("bob", session("bob", "a"));
sessions.put("eve", session("eve", "b"));
config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionSupplier(new QCodeReference(TestSessions.class));
QContext.getQSession().getUser().setFullName("Alice");
CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "eve").withValue("displayName", "Sam"));
WorkProcesses.assignments("document").forEach(instance::addProcess);
new QInstanceValidator().validate(instance);
}
/*******************************************************************************
** Test Notifier.
*******************************************************************************/
public static class TestNotifier implements CollaborationNotifier
{
/*******************************************************************************
** Use the configured test boundary.
*******************************************************************************/
@Override
public void notify(CollaborationNotification notification)
{
recorder.notify(notification);
}
}
/*******************************************************************************
** Test Sessions.
*******************************************************************************/
public static class TestSessions implements UserSessionSupplier
{
/*******************************************************************************
** Use the configured test boundary.
*******************************************************************************/
@Override
public Optional<QSession> sessionFor(String user)
{
return (Optional.ofNullable(sessions.get(user)));
}
}
/*******************************************************************************
** Directory.
*******************************************************************************/
public static class Directory implements PermissionHolderDirectory
{
/*******************************************************************************
** Use the configured test boundary.
*******************************************************************************/
@Override
public Set<String> userIdsWithPermission(String permission)
{
return (permission.equals("collaboration.admin") ? Set.of("admin") : Set.of("approver", "admin"));
}
}
/*******************************************************************************
** Session.
*******************************************************************************/
private static QSession session(String user, String tenant)
{
return (new QSession().withUser(new QUser().withIdReference(user)).withPermissions("document.read", "document.task").withSecurityKeyValue("tenant", tenant));
}
/*******************************************************************************
** Links Preview And Recipient Safe Shares.
*******************************************************************************/
@Test
void linksPreviewAndRecipientSafeShares() throws Exception
{
assertThat(sharing.link("document", "01")).isEqualTo(new SharingService.Link("/app/document/1", "First document"));
config.withFrontendBaseUrl("https://example.test/");
assertThat(sharing.link("document", 1).url()).isEqualTo("https://example.test/app/document/1");
assertThat(TargetResolver.recordHref("a b", "a/b?#")).isEqualTo("https://example.test/app/a%20b/a%2Fb%3F%23");
assertThat(sharing.preview("document", 1, List.of("bob", "eve", "missing")))
.containsExactly(new SharingService.Recipient("bob", "bob", true), new SharingService.Recipient("eve", "Sam", false), new SharingService.Recipient("missing", "missing", null));
List<QRecord> shares = sharing.share("document", 1, List.of("bob", "eve", "missing"), "First document confidential contents");
assertThat(shares).hasSize(3);
assertThat(recorder.notifications()).hasSize(3);
assertThat(recorder.notifications().getFirst().title()).contains("Alice shared First document with you");
for(CollaborationNotification notification : recorder.notifications().subList(1, 3))
{
assertThat(notification.title() + notification.body()).doesNotContain("First document", "confidential contents");
assertThat(notification.title()).contains("Documents");
assertThat(notification.link()).startsWith("/app/collaboration_access_request/create?tableName=document&recordId=1&shareId=");
assertThat(notification.sourceTableName()).isNull();
}
assertThat(new FollowService().followers("document", 1)).singleElement().satisfies(row ->
{
assertThat(row.userId()).isEqualTo("bob");
assertThat(row.reason()).isEqualTo("SHARED");
});
assertThat(rows(CollaborationTables.ACTIVITY).stream().filter(row -> "SHARED".equals(row.getValueString("type")))).hasSize(3);
assertThatThrownBy(() -> sharing.markOpened(shares.getFirst().getValueInteger("id"))).hasMessage("Not found");
login("bob");
sharing.markOpened(shares.getFirst().getValueInteger("id"));
Instant opened = rows(CollaborationTables.SHARE).getFirst().getValueInstant("openedAt");
sharing.markOpened(shares.getFirst().getValueInteger("id"));
assertThat(rows(CollaborationTables.SHARE).getFirst().getValueInstant("openedAt")).isEqualTo(opened);
}
/*******************************************************************************
** Sharing Validation And After Commit Checks.
*******************************************************************************/
@Test
void sharingValidationAndAfterCommitChecks() throws Exception
{
assertThatThrownBy(() -> sharing.preview("document", 1, List.of())).hasMessageContaining("1 to 25");
assertThatThrownBy(() -> sharing.preview("document", 1, java.util.Collections.nCopies(26, "bob"))).hasMessageContaining("1 to 25");
assertThatThrownBy(() -> sharing.share("document", 1, List.of("bob"), "x".repeat(2001))).hasMessageContaining("2000");
assertThatThrownBy(() -> sharing.link("document", 3)).hasMessage("Not found");
QBackendTransaction tx = new QBackendTransaction();
QContext.setTransaction(tx);
sharing.share("document", 1, List.of("bob", "eve"), "First document");
assertThat(recorder.notifications()).isEmpty();
sessions.put("bob", session("bob", "b"));
tx.commit();
QContext.clearTransaction();
assertThat(recorder.notifications()).singleElement().satisfies(n -> assertThat(n.recipientUserId()).isEqualTo("eve"));
QContext.getQSession().setPermissions(Set.of("document.read"));
assertThatThrownBy(() -> sharing.preview("document", 1, List.of("bob"))).hasMessageContaining("share");
QContext.getQSession().setPermissions(Set.of("document.read", "document.share"));
assertThat(sharing.preview("document", 1, List.of("alice"))).singleElement().satisfies(r -> assertThat(r.canRead()).isTrue());
}
/*******************************************************************************
** Requests Do Not Reveal Existence And Remain Owner Locked.
*******************************************************************************/
@Test
void requestsDoNotRevealExistenceAndRemainOwnerLocked() throws Exception
{
QContext.getQSession().setPermissions(Set.of());
QRecord existing = access.request("document", 3, "Please", null);
QRecord missing = access.request("document", 999, "Please", null);
assertThat(existing.getValues().keySet()).isEqualTo(missing.getValues().keySet());
for(String field : existing.getValues().keySet())
{
if(!List.of("id", "recordId", "createDate", "modifyDate").contains(field))
{
assertThat(existing.getValue(field)).isEqualTo(missing.getValue(field));
}
}
assertThat(existing.getValues()).doesNotContainKeys("label", "recordLabel", "title");
assertThat(access.request("document", "03", "Changed", null).getValue("id")).isEqualTo(existing.getValue("id"));
assertThat(visibleRequests()).hasSize(2);
assertThat(visibleRequests().getFirst().getDisplayValue("tableName")).isEqualTo("Documents");
login("bob");
assertThat(visibleRequests()).isEmpty();
assertThatThrownBy(() -> access.withdraw(existing.getValueInteger("id"))).hasMessage("Not found");
admin();
assertThat(visibleRequests()).hasSize(2);
login("alice");
access.withdraw(existing.getValueInteger("id"));
assertThatThrownBy(() -> access.withdraw(existing.getValueInteger("id"))).hasMessageContaining("no longer open");
assertThat(access.request("document", 3, null, null).getValue("id")).isNotEqualTo(existing.getValue("id"));
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(CollaborationTables.ACCESS_REQUEST).withRecords(List.of(new QRecord()))))
.hasMessageContaining("service actions");
}
/*******************************************************************************
** Requests Resolve Directory And Owners And Record Outcomes.
*******************************************************************************/
@Test
void requestsResolveDirectoryAndOwnersAndRecordOutcomes() throws Exception
{
config.withPermissionHolderDirectory(new QCodeReference(Directory.class));
CollaborationRecords.insert(CollaborationTables.FEATURE_SETTING, new QRecord().withValue("scopeType", "TABLE").withValue("scopeName", "document")
.withValue("feature", "SHARING").withValue("enabled", true).withValue("settings", "{\"accessApproverPermission\":\"document.approve\"}"));
assignments.assign("document", 1, "bob", null);
QRecord request = access.request("document", 1, "First document", null);
assertThat(recorder.notifications()).extracting(CollaborationNotification::recipientUserId).containsExactlyInAnyOrder("admin", "approver", "bob");
assertThat(recorder.notifications()).allSatisfy(n -> assertThat(n.title() + n.body()).doesNotContain("First document"));
assertThatThrownBy(() -> access.handle(request.getValueInteger("id"), "GRANTED", null)).hasMessageContaining("approver permission");
login("approver", "document.approve");
assertThat(visibleRequests()).isEmpty();
access.handle(request.getValueInteger("id"), "GRANTED", "Changed in identity system");
assertThat(rows(CollaborationTables.ACCESS_REQUEST).getFirst().getValueString("status")).isEqualTo("GRANTED");
assertThat(recorder.notifications().getLast().recipientUserId()).isEqualTo("alice");
assertThat(recorder.notifications().getLast().body()).doesNotContain("Changed in identity system");
assertThat(QContext.getQSession().hasPermission("document.approve")).isTrue();
}
/*******************************************************************************
** Share References Cannot Be Forged.
*******************************************************************************/
@Test
void shareReferencesCannotBeForged() throws Exception
{
Integer share = sharing.share("document", 1, List.of("eve"), "secret").getFirst().getValueInteger("id");
assertThatThrownBy(() -> access.request("document", 1, null, share)).hasMessage("Not found");
login("eve");
assertThatThrownBy(() -> access.request("document", 2, null, share)).hasMessage("Not found");
assertThat(access.request("document", 1, null, share).getValueInteger("shareId")).isEqualTo(share);
sharing.markOpened(share);
}
/*******************************************************************************
** Tasks Refuse Unknown Assignees And Use Spec Reminder Defaults.
*******************************************************************************/
@Test
void tasksRefuseUnknownAssigneesAndUseSpecReminderDefaults() throws Exception
{
assertThatThrownBy(() -> tasks.create("document", 1, "Work", null, "eve", null, null)).hasMessage("Sam can't open this record.");
assertThatThrownBy(() -> tasks.create("document", 1, "Work", null, "missing", null, null)).hasMessageContaining("can't open");
assertThatThrownBy(() -> tasks.create("document", 1, "x".repeat(201), null, null, null, null)).hasMessageContaining("200");
QRecord future = tasks.create("document", 1, "Future", "Notes", "bob", NOW.plusSeconds(172800), null);
assertThat(future.getValueInstant("remindAt")).isEqualTo(NOW.plusSeconds(86400));
assertThat(recorder.notifications()).singleElement().satisfies(n -> assertThat(n.title()).contains("Future"));
assertThat(new FollowService().followers("document", 1)).singleElement().satisfies(f -> assertThat(f.reason()).isEqualTo("ASSIGNED"));
QRecord soon = tasks.create("document", 1, "Soon", null, null, NOW.plusSeconds(3600), null);
assertThat(soon.getValueInstant("remindAt")).isEqualTo(soon.getValueInstant("dueAt"));
assertThat(soon.getValueString("assigneeUserId")).isEqualTo("alice");
config.withUserSessionSupplier(null);
assertThat(tasks.create("document", 1, "Self", null, null, null, null).getValueInstant("remindAt")).isNull();
}
/*******************************************************************************
** Task Transitions Updates And Mine Ordering.
*******************************************************************************/
@Test
void taskTransitionsUpdatesAndMineOrdering() throws Exception
{
QRecord undated = tasks.create("document", 1, "Undated", null, null, null, null);
QRecord later = tasks.create("document", 1, "Later", null, null, NOW.plusSeconds(200), null);
QRecord first = tasks.create("document", 2, "First", null, null, NOW.plusSeconds(100), null);
QRecord tie = tasks.create("document", 2, "Tie", null, null, NOW.plusSeconds(100), null);
TasksService.Page page = tasks.mine(null, 2, null);
assertThat(page.tasks()).extracting(r -> r.getValueString("title")).containsExactly("First", "Tie");
assertThat(tasks.mine("OPEN", 2, page.nextCursor()).tasks()).extracting(r -> r.getValueString("title")).containsExactly("Later", "Undated");
assertThatThrownBy(() -> tasks.mine(null, 0, null)).hasMessageContaining("Limit");
assertThatThrownBy(() -> tasks.mine(null, 20, "bad")).hasMessageContaining("cursor");
assertThatThrownBy(() -> tasks.mine("NOPE", 20, null)).hasMessageContaining("status");
tasks.complete(first.getValueInteger("id"));
tasks.complete(first.getValueInteger("id"));
assertThat(tasks.mine("DONE", 10, null).tasks()).singleElement().satisfies(r -> assertThat(r.getValueInstant("completedAt")).isEqualTo(NOW));
assertThat(rows(CollaborationTables.ACTIVITY).stream().filter(r -> "TASK_DONE".equals(r.getValueString("type")))).hasSize(1);
tasks.reopen(first.getValueInteger("id"));
assertThat(tasks.list("document", 2, false)).allSatisfy(r -> assertThat(r.getValueInstant("completedAt")).isNull());
tasks.cancel(tie.getValueInteger("id"));
assertThat(tasks.list("document", 2, false)).hasSize(1);
assertThat(tasks.list("document", 2, true)).hasSize(2);
tasks.update(later.getValueInteger("id"), "Updated", "notes", "bob", NOW, NOW.minusSeconds(10));
login("bob");
tasks.complete(later.getValueInteger("id"));
assertThatThrownBy(() -> tasks.complete(undated.getValueInteger("id"))).hasMessageContaining("creator, assignee");
admin();
tasks.complete(undated.getValueInteger("id"));
}
/*******************************************************************************
** Reminders And Overdue Are Delivered Once With Fresh Access.
*******************************************************************************/
@Test
void remindersAndOverdueAreDeliveredOnceWithFreshAccess() throws Exception
{
QRecord task = tasks.create("document", 1, "Scheduled", null, "bob", NOW.plusSeconds(100), NOW);
recorder = new RecordingNotifier();
assertThatThrownBy(() -> new TaskReminders(CLOCK).sendDue()).hasMessageContaining("admin");
admin();
TaskReminders reminders = new TaskReminders(CLOCK);
reminders.sendDue();
reminders.sendDue();
assertThat(recorder.notifications()).hasSize(1);
assertThat(WorkSupport.row(CollaborationTables.TASK, task.getValueInteger("id")).getValueInstant("remindedAt")).isEqualTo(NOW);
new TaskReminders(Clock.fixed(NOW.plusSeconds(101), ZoneOffset.UTC)).sendDue();
new TaskReminders(Clock.fixed(NOW.plusSeconds(102), ZoneOffset.UTC)).sendDue();
assertThat(recorder.notifications()).hasSize(2);
assertThat(recorder.notifications().getLast().title()).contains("overdue");
tasks.update(task.getValueInteger("id"), "Changed", null, "bob", NOW.minusSeconds(1), NOW.minusSeconds(1));
sessions.put("bob", session("bob", "b"));
reminders.sendDue();
assertThat(recorder.notifications()).hasSize(2);
assertThat(WorkSupport.row(CollaborationTables.TASK, task.getValueInteger("id")).getValueInstant("remindedAt")).isNull();
sessions.put("bob", session("bob", "a"));
reminders.sendDue();
assertThat(recorder.notifications()).hasSize(4);
tasks.cancel(task.getValueInteger("id"));
reminders.sendDue();
assertThat(recorder.notifications()).hasSize(4);
QContext.setTransaction(new QBackendTransaction());
assertThatThrownBy(reminders::sendDue).hasMessageContaining("outside a transaction");
QContext.clearTransaction();
new TaskReminders().sendDue();
new SendTaskRemindersProcess().run(null, null);
}
/*******************************************************************************
** Assignments Upsert And My Records Respect Parent Locks.
*******************************************************************************/
@Test
void assignmentsUpsertAndMyRecordsRespectParentLocks() throws Exception
{
assertThatThrownBy(() -> assignments.assign("document", 1, "eve", null)).hasMessageContaining("can't open");
QRecord first = assignments.assign("document", 1, "alice", "bob");
QRecord again = assignments.assign("document", "01", "alice", "bob");
assertThat(again.getValue("id")).isEqualTo(first.getValue("id"));
assertThat(recorder.notifications()).hasSize(1);
assignments.assign("document", 2, null, "alice");
assertThat(assignments.myRecords(null, 1).get("document").count()).isEqualTo(2);
assertThat(assignments.myRecords("document", 1).get("document").records()).hasSize(1);
assertThatThrownBy(() -> assignments.myRecords(null, 101)).hasMessageContaining("Limit");
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_ASSIGNMENT))).hasMessageContaining("collaboration");
QContext.getQSession().getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("b")));
assertThat(assignments.myRecords(null, 10)).isEmpty();
assertThat(instance.getTable(CollaborationTables.RECORD_ASSIGNMENT).getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
}
/*******************************************************************************
** My Work Summarizes Only Visible Work And Read Mentions.
*******************************************************************************/
@Test
void myWorkSummarizesOnlyVisibleWorkAndReadMentions() throws Exception
{
for(int i = 0; i < 12; i++)
{
tasks.create("document", 1, "Task " + i, null, "bob", null, null);
}
assignments.assign("document", 1, "alice", "bob");
new CommentsService().post("document", 1, "@[Bob](user:bob)", null, null);
login("bob");
MyWorkService service = new MyWorkService();
MyWorkService.Summary summary = service.summary();
assertThat(summary.openTaskCount()).isEqualTo(12);
assertThat(summary.tasks()).hasSize(10);
assertThat(summary.assignedRecordsByTable()).containsEntry("document", 1L);
assertThat(summary.unreadMentionCount()).isEqualTo(1);
service.markMentionsRead(List.of(-1));
assertThat(service.summary().unreadMentionCount()).isEqualTo(1);
service.markMentionsRead(null);
assertThat(service.summary().unreadMentionCount()).isZero();
QContext.getQSession().getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("b")));
assertThat(service.summary().openTaskCount()).isZero();
assertThat(service.summary().assignedRecordsByTable()).isEmpty();
}
/*******************************************************************************
** Actual Streamed Actions Have Review And Result.
*******************************************************************************/
@Test
void actualStreamedActionsHaveReviewAndResult() throws Exception
{
login("alice", "collaboration.view");
QRecord task = tasks.create("document", 1, "Action", null, null, null, null);
RunProcessOutput review = review("collaborationDone_collaboration_task", task.getValueString("id"), Map.of());
assertThat(lines(review, false)).anySatisfy(line -> assertThat(line.getPluralFutureMessage()).contains("will be processed"));
assertThat(lines(finish(review), true)).anySatisfy(line -> assertThat(line.getPluralPastMessage()).contains("processed"));
assertThat(tasks.list("document", 1, false)).isEmpty();
finish(review("collaborationReopen_collaboration_task", task.getValueString("id"), Map.of()));
assertThat(tasks.list("document", 1, false)).hasSize(1);
finish(review("collaborationAssign_document", "1,2", Map.of("assigneeUserId", "bob")));
finish(review("collaborationOwn_document", "1,2", Map.of()));
assertThat(assignments.myRecords(null, 10).get("document").count()).isEqualTo(2);
assertThat(assignments.get("document", 1).orElseThrow().getValueString("assigneeUserId")).isEqualTo("bob");
}
/*******************************************************************************
** Access Actions Work For Admin And Configured Approver.
*******************************************************************************/
@Test
void accessActionsWorkForAdminAndConfiguredApprover() throws Exception
{
QRecord granted = access.request("document", 999, null, null);
QRecord declined = access.request("document", 998, null, null);
admin();
RunProcessOutput grant = review("collaborationGrant_collaboration_access_request", granted.getValueString("id"), Map.of("note", "Identity system updated"));
assertThat(grant.getValues().get(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE)).isEqualTo("Record the outcome; grant access in your identity system first.");
finish(grant);
CollaborationRecords.insert(CollaborationTables.FEATURE_SETTING, new QRecord().withValue("scopeType", "TABLE").withValue("scopeName", "document")
.withValue("feature", "SHARING").withValue("enabled", true).withValue("settings", "{\"accessApproverPermission\":\"document.approve\"}"));
login("approver", "document.approve");
finish(review("collaborationDecline_collaboration_access_request", declined.getValueString("id"), Map.of()));
assertThat(rows(CollaborationTables.ACCESS_REQUEST)).extracting(r -> r.getValueString("status")).containsExactly("GRANTED", "DECLINED");
assertThat(recorder.notifications()).extracting(CollaborationNotification::title).contains("Access request granted", "Access request declined");
}
/*******************************************************************************
** Cleanup Preserves Requests And Schema Includes Work Indexes.
*******************************************************************************/
@Test
void cleanupPreservesRequestsAndSchemaIncludesWorkIndexes() throws Exception
{
tasks.create("document", 1, "Deleted", null, null, null, null);
assignments.assign("document", 1, "alice", null);
sharing.share("document", 1, List.of("bob"), null);
access.request("document", 1, null, null);
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
assertThat(rows(CollaborationTables.TASK)).isEmpty();
assertThat(rows(CollaborationTables.RECORD_ASSIGNMENT)).isEmpty();
assertThat(rows(CollaborationTables.SHARE)).isEmpty();
assertThat(rows(CollaborationTables.ACCESS_REQUEST)).hasSize(1);
new WorkCleanup().onParentsDeleted("document", List.of(1));
new WorkCleanup().onParentsDeleted("document", List.of());
String sql = CollaborationSchema.postgresScript(config);
assertThat(sql).contains("collaboration_share", "collaboration_access_request", "collaboration_task", "collaboration_record_assignment", "overdue_notified_at",
"read_at", "WHERE status = 'OPEN'", "assignee_user_id, status, due_at", "requester_user_id", "owner_user_id");
for(String table : List.of(CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.TASK, CollaborationTables.RECORD_ASSIGNMENT))
{
assertThat(instance.getTable(table).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
}
assertThat(instance.getTable(CollaborationTables.TASK).getSearchFields()).containsExactly("title");
}
/*******************************************************************************
** Home Shows Real Counts.
*******************************************************************************/
@Test
void homeShowsRealCounts() throws Exception
{
tasks.create("document", 1, "Overdue", null, null, NOW.minusSeconds(60), null);
sharing.share("document", 1, List.of("bob"), null);
access.request("document", 999, null, null);
admin();
String html = CollaborationHome.render(Instant.now().plusSeconds(5));
assertThat(html).contains("Open access requests</h3><p>1", "Open tasks</h3><p>1", "Overdue tasks</h3><p>1", "Shares this week</h3><p>1");
assertThat(SendTaskRemindersProcess.metadata(config).getIsHidden()).isTrue();
assertThat(SendTaskRemindersProcess.metadata(config).getSchedule()).isNull();
assertThat(SendTaskRemindersProcess.metadata(config.withSchedulerName("clock")).getSchedule().getRepeatSeconds()).isEqualTo(60);
}
/*******************************************************************************
** Undated Tasks And No Op Delivery Never Receive Receipts.
*******************************************************************************/
@Test
void undatedTasksAndNoOpDeliveryNeverReceiveReceipts() throws Exception
{
tasks.create("document", 1, "No deadline", null, "bob", null, null);
QRecord due = tasks.create("document", 2, "Due", null, "bob", NOW.minusSeconds(1), NOW);
recorder = new RecordingNotifier();
admin();
config.withNotifier(new QCodeReference(com.kingsrook.qbits.collaboration.notify.NoOpNotifier.class));
new TaskReminders(CLOCK).sendDue();
assertThat(WorkSupport.row(CollaborationTables.TASK, due.getValueInteger("id")).getValueInstant("remindedAt")).isNull();
assertThat(WorkSupport.row(CollaborationTables.TASK, due.getValueInteger("id")).getValueInstant("overdueNotifiedAt")).isNull();
config.withNotifier(new QCodeReference(TestNotifier.class));
new TaskReminders(CLOCK).sendDue();
assertThat(recorder.notifications()).hasSize(2).allSatisfy(n -> assertThat(n.title()).doesNotContain("No deadline"));
}
/*******************************************************************************
** Cleanup Waits For Commit And Request Get Remains Owner Locked.
*******************************************************************************/
@Test
void cleanupWaitsForCommitAndRequestGetRemainsOwnerLocked() throws Exception
{
tasks.create("document", 1, "Cleanup", null, null, null, null);
QRecord request = access.request("document", 1, null, null);
QBackendTransaction tx = new QBackendTransaction();
QContext.setTransaction(tx);
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)).withTransaction(tx));
assertThat(rows(CollaborationTables.TASK)).hasSize(1);
tx.commit();
QContext.clearTransaction();
assertThat(rows(CollaborationTables.TASK)).isEmpty();
login("bob");
assertThat(new GetAction().execute(new GetInput(CollaborationTables.ACCESS_REQUEST).withPrimaryKey(request.getValue("id"))).getRecord()).isNull();
admin();
assertThat(new GetAction().execute(new GetInput(CollaborationTables.ACCESS_REQUEST).withPrimaryKey(request.getValue("id"))).getRecord()).isNotNull();
login("alice");
assertThat(new GetAction().execute(new GetInput(CollaborationTables.ACCESS_REQUEST).withPrimaryKey(request.getValue("id"))).getRecord()).isNotNull();
}
/*******************************************************************************
** Rows.
*******************************************************************************/
private List<QRecord> rows(String table) throws Exception
{
return (CollaborationRecords.query(table, new QQueryFilter()));
}
/*******************************************************************************
** Visible Requests.
*******************************************************************************/
private List<QRecord> visibleRequests() throws Exception
{
return (new QueryAction().execute(new QueryInput(CollaborationTables.ACCESS_REQUEST).withShouldGenerateDisplayValues(true)).getRecords());
}
/*******************************************************************************
** Review.
*******************************************************************************/
private RunProcessOutput review(String name, String records, Map<String, Serializable> values) throws Exception
{
RunProcessInput input = new RunProcessInput();
input.setProcessName(name);
input.addValue("testProcessName", name);
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
input.addValue(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
input.addValue("recordsParam", "recordIds");
input.addValue("recordIds", records);
values.forEach(input::addValue);
if(instance.getProcess(name).getStep("details") != null)
{
RunProcessOutput picker = new RunProcessAction().execute(input);
assertThat(picker.getException()).isEmpty();
input.setProcessUUID(picker.getProcessUUID());
input.setStartAfterStep("details");
}
RunProcessOutput output = new RunProcessAction().execute(input);
assertThat(output.getException()).isEmpty();
return (output);
}
/*******************************************************************************
** Finish.
*******************************************************************************/
private RunProcessOutput finish(RunProcessOutput review) throws Exception
{
RunProcessInput go = new RunProcessInput();
go.setProcessName((String) review.getValues().get("testProcessName"));
go.setProcessUUID(review.getProcessUUID());
go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW);
go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
RunProcessOutput result = new RunProcessAction().execute(go);
assertThat(result.getException()).isEmpty();
return (result);
}
/*******************************************************************************
** Read the standard streamed process summary.
*******************************************************************************/
@SuppressWarnings("unchecked")
private List<ProcessSummaryLine> lines(RunProcessOutput output, boolean result)
{
return ((List<ProcessSummaryLine>) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY));
}
}