Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 da932fb309 feat(attachments): honor the bucket upload cap via object storage's api (qbit-object-storage 0.1.0-RC.4)
AttachmentStorage.maxBytes now reads StorageBucket.maxUploadBytes() from
the api package (null = no bucket limit); uploads stop at the smaller of
the bucket cap and the per-table cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:55:21 -05:00
james.maes 66189ec110 Merge pull request #15 from QRun-IO/feature/email-qbit
feat(email): email mentions, shares and task notices through qbit-email
2026-10-14 08:27:25 -05:00
james.maesandClaude Opus 5.5 23d3365437 Merge develop into feature/email-qbit
Keeps develop's changes and re-applies the optional qbit-email 0.1.0-RC.1
delivery on top (adapted to develop's refactors; guardrail allow-lists
for the optional integration where the build parent enforces them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:19:03 -05:00
james.maesandClaude Opus 5.5 f0b8fc9141 build: qbit-email 0.1.0-RC.1 from Maven Central
Moves from 0.1.0-SNAPSHOT to the first release candidate. Test schemas
gain the RC's email_provider_sender_address table and the message
from_address_id column where they copy the qbit's DDL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:58:55 -05:00
james.maes 6cd78a803e Merge pull request #16 from QRun-IO/fix/live-review
fix: live-review fixes (tag id filter, home widgets, display names, demo multi-user)
2026-10-14 07:09:47 -05:00
james.maes b4032baf70 Merge pull request #14 from QRun-IO/feature/demo-dev-server
test: collaboration demo dev server with seeded data
2026-10-14 07:09:35 -05:00
james.maes d2ad22eb97 Merge pull request #13 from QRun-IO/feature/presence-custom-fields
feat: custom field designer and presence
2026-10-14 07:09:21 -05:00
james.maes 66b5bf2bea Merge pull request #8 from QRun-IO/feature/ui-api-endpoints
feat: adopt core collaboration contracts and the UI API extension endpoints
2026-10-14 07:09:09 -05:00
james.maesandClaude Opus 5.5 d828d1677d feat: friendly audit summaries on the activity timeline
Core's "Record was Edited/Inserted/Deleted" audit messages become "Edited by
Alice Morgan: Title, Total", "Created by Alice Morgan" and "Deleted by ...",
using personalized field labels (first four, then "and N more"). Detail rows
and unrecognized messages are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:28:43 -05:00
james.maesandClaude Opus 5.5 ef2ee45b5a feat: demo server picks the user per browser session and sends the real name
A ?user= request parameter (remembered in the collab_dev_user cookie) chooses the
demo identity for that browser, falling back to -Dcollab.dev.user. The session
now carries values.user (name and email) so the frontend shows Alice Morgan
instead of Anonymous. README documents opening a second browser to see presence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:27:08 -05:00
james.maesandClaude Opus 5.5 03669391d0 fix: exclude the caller from recipient and mention search; demo links are absolute
People search (share recipients and mention candidates) never returns the
current user. The record link already honors frontendBaseUrl; the demo server
now sets it to http://localhost:<port> and the test asserts the absolute URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:23:44 -05:00
james.maesandClaude Opus 5.5 7e63314413 feat: display names beside user ids in API payloads and admin tables
Tasks, attachments, comments' revisions, followers, shares, access requests and
assignments gain additive *DisplayName properties, resolved with one directory
query per response (unknown ids fall back to the id). User-id fields on the
collaboration tables use the collaboration_user possible value source, keyed on
userId, so admin screens show names. Contract regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:22:00 -05:00
james.maesandClaude Opus 5.5 68fe96c22f feat: rebuild the Collaboration home from standard widgets
Replace the HTML wall of text with a KPI block (comments this week, open
threads, open and overdue tasks, open access requests, attachments with a
human-readable size, active custom fields) and full-width Tags, Custom fields
and Who's here tables, each with a real empty state and no hard-coded colors.
Counts still use the caller's session. Tests updated to assert widget data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:14:27 -05:00
james.maesandClaude Opus 5.5 c9954efef4 feat(email): email mentions, shares and task notices through qbit-email
Phase (d) of QRun-IO/qbit-email docs/DESIGN.md section 11. qbit-email is an
optional dependency (0.1.0-SNAPSHOT until its first RC) used through one
adapter class behind a classpath and instance guard; without it the
existing behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:12:39 -05:00
james.maesandClaude Opus 5.5 e696d9edaf fix: demo Admin parent app is reachable so Collaboration lands in the app tree
The producer already nests the app under its parent, or leaves it top-level.
QQQ builds the appTree from apps whose parents survive permission checks, so a
denied parent silently orphaned Collaboration (present in apps, absent from the
tree). The demo granted admin.access while the Admin app requires
admin.hasAccess. Tests assert the tree for both placements and for the demo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:07:19 -05:00
james.maesandClaude Opus 5.5 42cf51325a fix: tag filter accepts ids as any JSON number or numeric string
TagCriteriaExpander treated non-Number values as names, so ids arriving
as strings from v1 JSON matched nothing. Numbers and numeric strings that
match an existing tag are ids; everything else is a normalized name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:04:54 -05:00
47 changed files with 1923 additions and 341 deletions

No files matched your search

+13
View File
@@ -4,6 +4,19 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
## [Unreleased]
### Changed
- Email through qbit-email 0.1.0-RC.1: new optional dependency and `EmailCollaborationNotifier` (templates `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder`), the default notifier when qbit-email is produced in the instance and no `notifier` is configured; `NoOpNotifier` otherwise. One NOTIFICATION message per recipient, no comment bodies, record labels only when field access allows. New `recipientEmailResolver` config hook; `CollaborationNotification` gains optional `sourceId` and `actorUserId`.
### Fixed
- Timeline audit summaries read "Edited by Alice Morgan: Title, Total" or "Created by Alice Morgan" instead of "Record was Edited"; detail rows are unchanged.
- The demo server chooses its user per browser session (`?user=bob`, remembered in a cookie) instead of once per JVM, and sends the person's name to the frontend so it no longer shows Anonymous.
- The share recipient search and mention candidates no longer offer the caller to themself.
- The demo server configures `frontendBaseUrl`, so record links are absolute URLs (the API already returned absolute links once it is set).
- Tag filters accept tag ids as any JSON number or numeric string; other strings remain names.
- Collaboration home rebuilt from standard KPI and table widgets with empty states; the demo Admin parent app is reachable so Collaboration appears in the app tree.
- Dependent possible values for comment parents (thread root/replies), feature-setting scope names (apps/eligible tables/instance), and comment field anchors (session-visible fields), with RC.5 action-level tests.
### Added
+23 -5
View File
@@ -51,9 +51,14 @@ of `;` between classpath entries.
Open [the demo](http://localhost:8098/?createMockSession=true), then **Sales → People**
or **Sales orders**. [Person 1](http://localhost:8098/app/person/1) and
[order 1](http://localhost:8098/app/sales_order/1) have the richest collaboration panels.
Select a user with `-Dcollab.dev.user=alice|bob|admin` (one value, not the literal list)
and restart the server to switch. The MOCK session customizer supplies stable identities
and real permissions, also used by recipient access checks:
Pick the user per browser: add `user=` to the page load, for example
`http://localhost:8098/?createMockSession=true&user=bob`. The server remembers the choice in a
`collab_dev_user` cookie, so **open a second browser with `?user=bob` to see presence** (your
first browser keeps its own user; browsers on the same profile share the cookie). Without a
choice, `-Dcollab.dev.user=alice|bob|admin` (one value, not the literal list) is the default.
The MOCK session customizer supplies stable identities, display names (the sidebar shows
Alice Morgan, not Anonymous) and real permissions, also used by recipient access checks.
Shared record links are absolute (`frontendBaseUrl` is `http://localhost:<port>`):
| User | Permissions |
| --- | --- |
@@ -134,7 +139,7 @@ Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation
All tables include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold.
The home reports persisted enabled settings by scope type, people known and people seen within the inclusive last seven days, plus Favorites and Recently viewed row counts and distinct user counts, attachment count and total bytes, comments in the last seven days, open root threads, and follower subscriptions (admins). Attachment aggregates are explicitly approximate until core can mirror parent locks. Recent activity is based on `lastSeenAt`, independently of the directory's `active` account flag. Empty states explain missing data. These counts do not represent resolved feature enablement on host tables.
The home is four full-width standard widgets (no custom HTML, no hard-coded colors, so dark mode works): an Overview of KPIs (comments this week, open threads, open and overdue tasks, open access requests, attachments with a human-readable total size such as 4.7 KB, active custom fields), a Tags table (label, table or All tables, records), a Custom fields table (name, table, status, index state) and an admin-only Who's here table of live viewer counts per table. Every block has a real empty-state message. Counts use the caller's session and permissions; attachment aggregates remain approximate until core can mirror parent locks.
## Permissions
@@ -264,7 +269,8 @@ UI API endpoints are described below. Runtime feature enablement, mirrored WRITE
| `defaultMaxBytes` | `104857600` (100 MiB) | Positive byte limit, capped by the bucket's `maxUploadBytes`. |
| `blockedExtensions` | Empty | Case-insensitive extension denylist, with or without leading dots. |
| `recentsPerUser` | `200` | Positive retention count per user. Enforced by the trim process. |
| `notifier` | `QCodeReference(NoOpNotifier.class)` | Implements `CollaborationNotifier`; replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released. |
| `notifier` | None | Implements `CollaborationNotifier`; replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released. When unset: `EmailCollaborationNotifier` if qbit-email is on the classpath and produced in the instance, otherwise `NoOpNotifier`. |
| `recipientEmailResolver` | People directory | `Function<String, String>` from user id to email address for the email notifier. Unset reads the `email` of the matching `collaboration_user` row; no address means no email. |
| `userSessionProvider` | None | `QCodeReference` implementing `UserSessionProviderInterface`; missing user sessions mean unknown access. |
| `followActionTables` | Empty | Existing host tables to receive standard Follow and Unfollow Review/Result processes. |
| `followChangeTables` | Empty | Existing host tables whose UPDATE events should produce five-minute follow digests. |
@@ -272,6 +278,18 @@ UI API endpoints are described below. Runtime feature enablement, mirrored WRITE
| `parentAppName` | None | Optional existing parent, such as `admin`. |
| `adminApp` | `true` | Set false to omit the app/home. Record child-list widgets remain available. |
## Email through qbit-email
qbit-email 0.1.0-RC.1 is an optional dependency (`<optional>true</optional>`). When it is on the classpath, produced in the same instance, and `notifier` is not configured, mentions, shares and task notifications are emailed by `EmailCollaborationNotifier` (package `email`, the only class that references qbit-email). Without qbit-email nothing changes: `NoOpNotifier` sends nothing. A configured `notifier` always wins.
The parent 2.1.0 guardrail allowlists explicitly include the qbit-email artifact and API package, preserving the parent's existing allowances. The package allowance includes the root because `EmailRuntime.config` lives there; keep all direct email references inside the adapter. Strict UI API route checks are enabled.
- Templates (NOTIFICATION, Markdown, owner `qbit-collaboration`), registered on first send and never overwritten: `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder` (task assignment, reminder and overdue). Follow digests are not emailed.
- One message per recipient, idempotency key `collaboration:<mention|share|task>:<event id>:<recipient user id>`, linked to the parent record, sender `qbit-collaboration`. A repeat is a duplicate. The event id is the mention, share, task or assignment row id (task reminders add the due field); `CollaborationNotification` carries it as `sourceId`, with the acting user as `actorUserId`.
- No comment bodies, task titles, notes or share messages are emailed, since they may hold PHI. The email says who did what on which record, with a link. The record label is read as the recipient (needs `userSessionProvider`) and used only when `FieldAccessHelper.isRecordLabelFullAccess` allows it (reflective guard); otherwise the text says "a record in <table label>". Variables are classified CONFIDENTIAL so the templates need no PHI approval.
- The link is sent only when absolute: set `frontendBaseUrl`.
- Recipient addresses come from `withRecipientEmailResolver(Function<userId, String>)`, else the people directory email. Refused or suppressed sends are logged by user id and status only; delivery problems never fail the collaboration action.
## Adopting in another app
1. Register the host backend and optional Admin app, then invoke the producer above.
+20 -1
View File
@@ -22,6 +22,12 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<!-- qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.1</qbit-email.version>
<!-- Optional email integration: only EmailCollaborationNotifier links to its API. -->
<!-- EmailRuntime is in the root package; retain the parent's existing allowances. -->
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-email</qbit.guardrails.allowedQbitArtifacts>
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email</qbit.guardrails.allowedApiPackages>
</properties>
<parent>
@@ -74,12 +80,25 @@
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-object-storage</artifactId>
<version>0.1.0-RC.2</version>
<version>0.1.0-RC.4</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
</dependency>
<!-- optional: email through qbit-email when it is produced in the host instance (EmailCollaborationNotifier) -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<optional>true</optional>
<exclusions>
<exclusion>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
@@ -25,7 +25,7 @@ package com.kingsrook.qbits.collaboration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.notify.NoOpNotifier;
import java.util.function.Function;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
@@ -46,7 +46,8 @@ public class CollaborationQBitConfig implements QBitConfig
private Map<String, List<RecordFeature>> tableFeatures = new LinkedHashMap<>();
private List<String> mirroredSecurityKeyTypes = List.of();
private QCodeReference notifier = new QCodeReference(NoOpNotifier.class);
private QCodeReference notifier = null;
private Function<String, String> recipientEmailResolver = null;
private QCodeReference userSessionProvider = null;
private String defaultTagMode = "FREE";
private List<String> tagActionTables = List.of();
@@ -301,7 +302,6 @@ public class CollaborationQBitConfig implements QBitConfig
assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors);
}
assertCondition(List.of("FREE", "APPROVED_ONLY").contains(defaultTagMode == null ? "" : defaultTagMode), "defaultTagMode must be FREE or APPROVED_ONLY", errors);
assertCondition(notifier != null, "notifier is required", errors);
for(List<String> tables : List.of(followActionTables, followChangeTables, tagActionTables, assignActionTables))
{
for(String table : tables)
@@ -424,13 +424,32 @@ public class CollaborationQBitConfig implements QBitConfig
}
/*******************************************************************************
** Get notifier.
** Get notifier. Null (the default) means: email through qbit-email when it is
** produced in the instance, otherwise no delivery transport.
*******************************************************************************/
public QCodeReference getNotifier()
{
return (notifier);
}
/*******************************************************************************
** Get the user id to email address lookup used by the email notifier.
*******************************************************************************/
public Function<String, String> getRecipientEmailResolver()
{
return (recipientEmailResolver);
}
/*******************************************************************************
** Configure how the email notifier finds a recipient address from a user id.
** Null (the default) uses the email stored in the collaboration people directory.
*******************************************************************************/
public CollaborationQBitConfig withRecipientEmailResolver(Function<String, String> recipientEmailResolver)
{
this.recipientEmailResolver = recipientEmailResolver;
return (this);
}
/*******************************************************************************
** Configure notifier.
*******************************************************************************/
@@ -162,7 +162,7 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
}
if(Boolean.TRUE.equals(qBitConfig.getAdminApp()))
{
output.add(CollaborationHome.widget());
CollaborationHome.widgets().forEach(output::add);
output.add(CollaborationHome.app(qBitConfig));
}
output.addSelfToInstance(instance);
@@ -70,7 +70,7 @@ public final class CollaborationSchemas
/*******************************************************************************
** An attachment visible through its parent record; no storage coordinates are exposed.
*******************************************************************************/
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete) implements ToSchema
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName) implements ToSchema
{
/*******************************************************************************
** Describe this versioned body for generated clients.
@@ -89,7 +89,8 @@ public final class CollaborationSchemas
Map.entry("description", new Schema().withType(Type.STRING).withDescription("Optional description.").withNullable(true)),
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("Uploader identity.").withNullable(true)),
Map.entry("createDate", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Upload time.").withNullable(true)),
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true))))));
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true)),
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Uploader display name.").withNullable(true))))));
}
}
@@ -201,7 +202,7 @@ public final class CollaborationSchemas
/*******************************************************************************
** A previous comment body.
*******************************************************************************/
public record CollaborationCommentRevisionV1(Integer id, String body, Instant editedAt, String editedByUserId) implements ToSchema
public record CollaborationCommentRevisionV1(Integer id, String body, Instant editedAt, String editedByUserId, String editedByDisplayName) implements ToSchema
{
/*******************************************************************************
** Describe this versioned body for generated clients.
@@ -213,14 +214,15 @@ public final class CollaborationSchemas
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Revision identifier.").withNullable(true)),
Map.entry("body", new Schema().withType(Type.STRING).withDescription("Previous body text.").withNullable(true)),
Map.entry("editedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Time the body was replaced.").withNullable(true)),
Map.entry("editedByUserId", new Schema().withType(Type.STRING).withDescription("Editor identity.").withNullable(true))))));
Map.entry("editedByUserId", new Schema().withType(Type.STRING).withDescription("Editor identity.").withNullable(true)),
Map.entry("editedByDisplayName", new Schema().withType(Type.STRING).withDescription("Editor display name.").withNullable(true))))));
}
}
/*******************************************************************************
** A subscription to record changes and comments.
*******************************************************************************/
public record CollaborationFollowV1(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil) implements ToSchema
public record CollaborationFollowV1(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil, String userDisplayName) implements ToSchema
{
/*******************************************************************************
** Describe this versioned body for generated clients.
@@ -234,7 +236,8 @@ public final class CollaborationSchemas
Map.entry("reason", new Schema().withType(Type.STRING).withEnumValues(List.of("MANUAL", "AUTHOR", "COMMENTED", "MENTIONED", "ASSIGNED", "SHARED")).withDescription("How following began.").withNullable(true)),
Map.entry("notifyOnChange", new Schema().withType(Type.BOOLEAN).withDescription("Receive record change digests.").withNullable(true)),
Map.entry("notifyOnComment", new Schema().withType(Type.BOOLEAN).withDescription("Receive new comment notifications.").withNullable(true)),
Map.entry("mutedUntil", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Notifications muted until this time, if set.").withNullable(true))))));
Map.entry("mutedUntil", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Notifications muted until this time, if set.").withNullable(true)),
Map.entry("userDisplayName", new Schema().withType(Type.STRING).withDescription("Follower display name.").withNullable(true))))));
}
}
@@ -678,14 +681,14 @@ public final class CollaborationSchemas
{
Map<String, Schema> schemas = new TreeMap<>();
schemas.put("CollaborationFavoriteV1", new CollaborationFavoriteV1(null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationAttachmentV1", new CollaborationAttachmentV1(null, null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationAttachmentV1", new CollaborationAttachmentV1(null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationTagV1", new CollaborationTagV1(null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationPersonV1", new CollaborationPersonV1(null, null, null, null).toSchema());
schemas.put("CollaborationMentionV1", new CollaborationMentionV1(null, null).toSchema());
schemas.put("CollaborationMentionCandidateV1", new CollaborationMentionCandidateV1(null, null).toSchema());
schemas.put("CollaborationCommentV1", new CollaborationCommentV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationCommentRevisionV1", new CollaborationCommentRevisionV1(null, null, null, null).toSchema());
schemas.put("CollaborationFollowV1", new CollaborationFollowV1(null, null, null, null, null, null).toSchema());
schemas.put("CollaborationCommentRevisionV1", new CollaborationCommentRevisionV1(null, null, null, null, null).toSchema());
schemas.put("CollaborationFollowV1", new CollaborationFollowV1(null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationFollowStateV1", new CollaborationFollowStateV1(null, null).toSchema());
schemas.put("CollaborationTimelineDetailV1", new CollaborationTimelineDetailV1(null, null, null, null).toSchema());
schemas.put("CollaborationTimelineItemV1", new CollaborationTimelineItemV1(null, null, null, null, null, null, null, null, null).toSchema());
@@ -22,6 +22,10 @@
package com.kingsrook.qbits.collaboration.api;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationAttachmentV1;
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationCommentRevisionV1;
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationCommentV1;
@@ -40,10 +44,13 @@ import com.kingsrook.qbits.collaboration.comments.MentionCandidate;
import com.kingsrook.qbits.collaboration.comments.MentionEntry;
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
import com.kingsrook.qbits.collaboration.follow.FollowEntry;
import com.kingsrook.qbits.collaboration.people.PeopleService;
import com.kingsrook.qbits.collaboration.people.PersonEntry;
import com.kingsrook.qbits.collaboration.tags.TagEntry;
import com.kingsrook.qbits.collaboration.timeline.TimelineDetail;
import com.kingsrook.qbits.collaboration.timeline.TimelineItem;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
@@ -67,13 +74,39 @@ final class CollaborationViews
value.id(), value.targetType(), value.targetName(), value.recordId(), value.label(), value.folder(), value.sortOrder(), value.available(), value.href()));
}
/*******************************************************************************
** One display name per distinct user id, from a single directory query.
*******************************************************************************/
static Map<String, String> names(String... userIds) throws QException
{
return (new PeopleService().displayNames(Arrays.asList(userIds)));
}
/*******************************************************************************
** The display name for an id; null stays null, unknown ids show themselves.
*******************************************************************************/
private static String name(Map<String, String> names, String userId)
{
return (userId == null ? null : names.getOrDefault(userId, userId));
}
/*******************************************************************************
** Project attachment display data.
*******************************************************************************/
static CollaborationAttachmentV1 attachment(AttachmentEntry value)
static CollaborationAttachmentV1 attachment(AttachmentEntry value) throws QException
{
return (new CollaborationAttachmentV1(
value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete()));
return (attachments(List.of(value)).getFirst());
}
/*******************************************************************************
** Project attachments, resolving uploader names in one batch.
*******************************************************************************/
static List<CollaborationAttachmentV1> attachments(List<AttachmentEntry> values) throws QException
{
Map<String, String> names = names(values.stream().map(AttachmentEntry::createdByUserId).toArray(String[]::new));
return (values.stream().map(value -> new CollaborationAttachmentV1(
value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete(),
name(names, value.createdByUserId()))).toList());
}
/*******************************************************************************
@@ -115,19 +148,29 @@ final class CollaborationViews
/*******************************************************************************
** Project commentrevision display data.
*******************************************************************************/
static CollaborationCommentRevisionV1 commentRevision(CommentRevision value)
static List<CollaborationCommentRevisionV1> commentRevisions(List<CommentRevision> values) throws QException
{
return (new CollaborationCommentRevisionV1(
value.id(), value.body(), value.editedAt(), value.editedByUserId()));
Map<String, String> names = names(values.stream().map(CommentRevision::editedByUserId).toArray(String[]::new));
return (values.stream().map(value -> new CollaborationCommentRevisionV1(
value.id(), value.body(), value.editedAt(), value.editedByUserId(), name(names, value.editedByUserId()))).toList());
}
/*******************************************************************************
** Project follow display data.
*******************************************************************************/
static CollaborationFollowV1 follow(FollowEntry value)
static CollaborationFollowV1 follow(FollowEntry value) throws QException
{
return (new CollaborationFollowV1(
value.id(), value.userId(), value.reason(), value.notifyOnChange(), value.notifyOnComment(), value.mutedUntil()));
return (follows(List.of(value)).getFirst());
}
/*******************************************************************************
** Project followers, resolving names in one batch.
*******************************************************************************/
static List<CollaborationFollowV1> follows(List<FollowEntry> values) throws QException
{
Map<String, String> names = names(values.stream().map(FollowEntry::userId).toArray(String[]::new));
return (values.stream().map(value -> new CollaborationFollowV1(
value.id(), value.userId(), value.reason(), value.notifyOnChange(), value.notifyOnComment(), value.mutedUntil(), name(names, value.userId()))).toList());
}
/*******************************************************************************
@@ -159,33 +202,70 @@ final class CollaborationViews
/*******************************************************************************
** Project Share public fields.
*******************************************************************************/
static CollaborationWorkSchemas.CollaborationShareV1 share(com.kingsrook.qqq.backend.core.model.data.QRecord row)
static CollaborationWorkSchemas.CollaborationShareV1 share(QRecord row) throws QException
{
return (new CollaborationWorkSchemas.CollaborationShareV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("fromUserId"), row.getValueString("toUserId"), row.getValueString("message"), row.getValueBoolean("recipientCouldRead"), row.getValueInstant("sentAt")));
return (shares(List.of(row)).getFirst());
}
/*******************************************************************************
** Project shares, resolving sender and recipient names in one batch.
*******************************************************************************/
static List<CollaborationWorkSchemas.CollaborationShareV1> shares(List<QRecord> rows) throws QException
{
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("fromUserId"), row.getValueString("toUserId"))).toArray(String[]::new));
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationShareV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
row.getValueString("fromUserId"), row.getValueString("toUserId"), row.getValueString("message"), row.getValueBoolean("recipientCouldRead"), row.getValueInstant("sentAt"),
name(names, row.getValueString("fromUserId")), name(names, row.getValueString("toUserId")))).toList());
}
/*******************************************************************************
** Project AccessRequest public fields.
*******************************************************************************/
static CollaborationWorkSchemas.CollaborationAccessRequestV1 accessRequest(com.kingsrook.qqq.backend.core.model.data.QRecord row)
static CollaborationWorkSchemas.CollaborationAccessRequestV1 accessRequest(QRecord row) throws QException
{
return (new CollaborationWorkSchemas.CollaborationAccessRequestV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("requesterUserId"), row.getValueString("message"), row.getValueString("status"), row.getValueInstant("handledAt"), row.getValueString("note")));
return (accessRequests(List.of(row)).getFirst());
}
/*******************************************************************************
** Project access requests, resolving requester and handler names in one batch.
*******************************************************************************/
static List<CollaborationWorkSchemas.CollaborationAccessRequestV1> accessRequests(List<QRecord> rows) throws QException
{
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("requesterUserId"), row.getValueString("handledByUserId"))).toArray(String[]::new));
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationAccessRequestV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
row.getValueString("requesterUserId"), row.getValueString("message"), row.getValueString("status"), row.getValueInstant("handledAt"), row.getValueString("note"),
name(names, row.getValueString("requesterUserId")), name(names, row.getValueString("handledByUserId")))).toList());
}
/*******************************************************************************
** Project Task public fields.
*******************************************************************************/
static CollaborationWorkSchemas.CollaborationTaskV1 task(com.kingsrook.qqq.backend.core.model.data.QRecord row)
static CollaborationWorkSchemas.CollaborationTaskV1 task(QRecord row) throws QException
{
return (new CollaborationWorkSchemas.CollaborationTaskV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("title"), row.getValueString("notes"), row.getValueString("assigneeUserId"), row.getValueInstant("dueAt"), row.getValueInstant("remindAt"), row.getValueString("status"), row.getValueString("createdByUserId"), row.getValueInstant("completedAt"), row.getValueString("completedByUserId")));
return (tasks(List.of(row)).getFirst());
}
/*******************************************************************************
** Project tasks, resolving assignee, creator and completer names in one batch.
*******************************************************************************/
static List<CollaborationWorkSchemas.CollaborationTaskV1> tasks(List<QRecord> rows) throws QException
{
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("assigneeUserId"), row.getValueString("createdByUserId"),
row.getValueString("completedByUserId"))).toArray(String[]::new));
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationTaskV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
row.getValueString("title"), row.getValueString("notes"), row.getValueString("assigneeUserId"), row.getValueInstant("dueAt"), row.getValueInstant("remindAt"), row.getValueString("status"),
row.getValueString("createdByUserId"), row.getValueInstant("completedAt"), row.getValueString("completedByUserId"),
name(names, row.getValueString("assigneeUserId")), name(names, row.getValueString("createdByUserId")), name(names, row.getValueString("completedByUserId")))).toList());
}
/*******************************************************************************
** Project Assignment public fields.
*******************************************************************************/
static CollaborationWorkSchemas.CollaborationAssignmentV1 assignment(com.kingsrook.qqq.backend.core.model.data.QRecord row)
static CollaborationWorkSchemas.CollaborationAssignmentV1 assignment(QRecord row) throws QException
{
return (new CollaborationWorkSchemas.CollaborationAssignmentV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("ownerUserId"), row.getValueString("assigneeUserId")));
Map<String, String> names = names(row.getValueString("ownerUserId"), row.getValueString("assigneeUserId"));
return (new CollaborationWorkSchemas.CollaborationAssignmentV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
row.getValueString("ownerUserId"), row.getValueString("assigneeUserId"), name(names, row.getValueString("ownerUserId")), name(names, row.getValueString("assigneeUserId"))));
}
}
@@ -95,7 +95,7 @@ public final class CollaborationWorkSchemas
/*******************************************************************************
** Versioned Share body.
*******************************************************************************/
public record CollaborationShareV1(Integer id, String tableName, String recordId, String fromUserId, String toUserId, String message, Boolean recipientCouldRead, Instant sentAt) implements ToSchema
public record CollaborationShareV1(Integer id, String tableName, String recordId, String fromUserId, String toUserId, String message, Boolean recipientCouldRead, Instant sentAt, String fromDisplayName, String toDisplayName) implements ToSchema
{
/*******************************************************************************
** Explicit schema for generated clients.
@@ -111,7 +111,9 @@ public final class CollaborationWorkSchemas
Map.entry("toUserId", new Schema().withType(Type.STRING).withDescription("toUserId.").withNullable(true)),
Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)),
Map.entry("recipientCouldRead", new Schema().withType(Type.BOOLEAN).withDescription("recipientCouldRead.").withNullable(true)),
Map.entry("sentAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("sentAt.").withNullable(true))))));
Map.entry("sentAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("sentAt.").withNullable(true)),
Map.entry("fromDisplayName", new Schema().withType(Type.STRING).withDescription("Sender display name.").withNullable(true)),
Map.entry("toDisplayName", new Schema().withType(Type.STRING).withDescription("Recipient display name.").withNullable(true))))));
}
}
@@ -151,7 +153,7 @@ public final class CollaborationWorkSchemas
/*******************************************************************************
** Versioned AccessRequest body.
*******************************************************************************/
public record CollaborationAccessRequestV1(Integer id, String tableName, String recordId, String requesterUserId, String message, String status, Instant handledAt, String note) implements ToSchema
public record CollaborationAccessRequestV1(Integer id, String tableName, String recordId, String requesterUserId, String message, String status, Instant handledAt, String note, String requesterDisplayName, String handledByDisplayName) implements ToSchema
{
/*******************************************************************************
** Explicit schema for generated clients.
@@ -167,7 +169,9 @@ public final class CollaborationWorkSchemas
Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)),
Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true)),
Map.entry("handledAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("handledAt.").withNullable(true)),
Map.entry("note", new Schema().withType(Type.STRING).withDescription("note.").withNullable(true))))));
Map.entry("note", new Schema().withType(Type.STRING).withDescription("note.").withNullable(true)),
Map.entry("requesterDisplayName", new Schema().withType(Type.STRING).withDescription("Requester display name.").withNullable(true)),
Map.entry("handledByDisplayName", new Schema().withType(Type.STRING).withDescription("Display name of the person who handled the request.").withNullable(true))))));
}
}
@@ -210,7 +214,7 @@ public final class CollaborationWorkSchemas
/*******************************************************************************
** Versioned Task body.
*******************************************************************************/
public record CollaborationTaskV1(Integer id, String tableName, String recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt, String status, String createdByUserId, Instant completedAt, String completedByUserId) implements ToSchema
public record CollaborationTaskV1(Integer id, String tableName, String recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt, String status, String createdByUserId, Instant completedAt, String completedByUserId, String assigneeDisplayName, String createdByDisplayName, String completedByDisplayName) implements ToSchema
{
/*******************************************************************************
** Explicit schema for generated clients.
@@ -230,7 +234,10 @@ public final class CollaborationWorkSchemas
Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true)),
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("createdByUserId.").withNullable(true)),
Map.entry("completedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("completedAt.").withNullable(true)),
Map.entry("completedByUserId", new Schema().withType(Type.STRING).withDescription("completedByUserId.").withNullable(true))))));
Map.entry("completedByUserId", new Schema().withType(Type.STRING).withDescription("completedByUserId.").withNullable(true)),
Map.entry("assigneeDisplayName", new Schema().withType(Type.STRING).withDescription("Assignee display name.").withNullable(true)),
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Creator display name.").withNullable(true)),
Map.entry("completedByDisplayName", new Schema().withType(Type.STRING).withDescription("Display name of the person who completed the task.").withNullable(true))))));
}
}
@@ -312,7 +319,7 @@ public final class CollaborationWorkSchemas
/*******************************************************************************
** Versioned Assignment body.
*******************************************************************************/
public record CollaborationAssignmentV1(Integer id, String tableName, String recordId, String ownerUserId, String assigneeUserId) implements ToSchema
public record CollaborationAssignmentV1(Integer id, String tableName, String recordId, String ownerUserId, String assigneeUserId, String ownerDisplayName, String assigneeDisplayName) implements ToSchema
{
/*******************************************************************************
** Explicit schema for generated clients.
@@ -325,7 +332,9 @@ public final class CollaborationWorkSchemas
Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("tableName.").withNullable(true)),
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("recordId.").withNullable(true)),
Map.entry("ownerUserId", new Schema().withType(Type.STRING).withDescription("ownerUserId.").withNullable(true)),
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true))))));
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true)),
Map.entry("ownerDisplayName", new Schema().withType(Type.STRING).withDescription("Owner display name.").withNullable(true)),
Map.entry("assigneeDisplayName", new Schema().withType(Type.STRING).withDescription("Assignee display name.").withNullable(true))))));
}
}
@@ -357,18 +366,18 @@ public final class CollaborationWorkSchemas
schemas.put("CollaborationLinkV1", new CollaborationLinkV1(null, null).toSchema());
schemas.put("CollaborationShareRecipientV1", new CollaborationShareRecipientV1(null, null, null).toSchema());
schemas.put("CollaborationSharePreviewV1", new CollaborationSharePreviewV1(null).toSchema());
schemas.put("CollaborationShareV1", new CollaborationShareV1(null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationShareV1", new CollaborationShareV1(null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationShareListV1", new CollaborationShareListV1(null).toSchema());
schemas.put("CollaborationShareRequestV1", new CollaborationShareRequestV1(null, null).toSchema());
schemas.put("CollaborationAccessRequestV1", new CollaborationAccessRequestV1(null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationAccessRequestV1", new CollaborationAccessRequestV1(null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationAccessRequestCreateRequestV1", new CollaborationAccessRequestCreateRequestV1(null, null, null, null).toSchema());
schemas.put("CollaborationAccessRequestListV1", new CollaborationAccessRequestListV1(null, null).toSchema());
schemas.put("CollaborationTaskV1", new CollaborationTaskV1(null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationTaskV1", new CollaborationTaskV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationTaskCreateRequestV1", new CollaborationTaskCreateRequestV1(null, null, null, null, null).toSchema());
schemas.put("CollaborationTaskPatchRequestV1", new CollaborationTaskPatchRequestV1(null, null, null, null, null, null).toSchema());
schemas.put("CollaborationTaskListV1", new CollaborationTaskListV1(null, null).toSchema());
schemas.put("CollaborationAssignmentRequestV1", new CollaborationAssignmentRequestV1(null, null).toSchema());
schemas.put("CollaborationAssignmentV1", new CollaborationAssignmentV1(null, null, null, null, null).toSchema());
schemas.put("CollaborationAssignmentV1", new CollaborationAssignmentV1(null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationMyWorkV1", new CollaborationMyWorkV1(null, null, null, null).toSchema());
return (schemas);
}
@@ -69,6 +69,6 @@ public class CommentHistorySpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var page = page(input, new CommentsService().history(id(input)));
return (new CollaborationCommentHistoryV1(page.items().stream().map(CollaborationViews::commentRevision).toList(), page.nextCursor()));
return (new CollaborationCommentHistoryV1(CollaborationViews.commentRevisions(page.items()), page.nextCursor()));
}
}
@@ -75,8 +75,8 @@ public class CreateSharesSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationWorkSchemas.CollaborationShareRequestV1.class);
return (new CollaborationWorkSchemas.CollaborationShareListV1(new com.kingsrook.qbits.collaboration.sharing.SharingService()
.share(table(input), pk(input), recipients(body.toUserIds()), body.message()).stream().map(CollaborationViews::share).toList()));
return (new CollaborationWorkSchemas.CollaborationShareListV1(CollaborationViews.shares(new com.kingsrook.qbits.collaboration.sharing.SharingService()
.share(table(input), pk(input), recipients(body.toUserIds()), body.message()))));
}
}
@@ -69,6 +69,6 @@ public class GetFollowSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var state = new FollowService().state(table(input), pk(input));
return (new CollaborationFollowStateV1(state.isPresent(), state.map(CollaborationViews::follow).orElse(null)));
return (new CollaborationFollowStateV1(state.isPresent(), state.isPresent() ? CollaborationViews.follow(state.get()) : null));
}
}
@@ -65,7 +65,7 @@ public class GetMyWorkSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var summary = new com.kingsrook.qbits.collaboration.mywork.MyWorkService().summary();
return (new CollaborationWorkSchemas.CollaborationMyWorkV1(summary.openTaskCount(), summary.tasks().stream().map(CollaborationViews::task).toList(),
return (new CollaborationWorkSchemas.CollaborationMyWorkV1(summary.openTaskCount(), CollaborationViews.tasks(summary.tasks()),
summary.assignedRecordsByTable(), summary.unreadMentionCount()));
}
@@ -69,6 +69,6 @@ public class ListAttachmentsSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var page = page(input, new AttachmentsService().list(table(input), pk(input)));
return (new CollaborationAttachmentListV1(page.items().stream().map(CollaborationViews::attachment).toList(), page.nextCursor()));
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), page.nextCursor()));
}
}
@@ -69,6 +69,6 @@ public class ListFollowersSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var page = page(input, new FollowService().followers(table(input), pk(input)));
return (new CollaborationFollowerListV1(page.items().stream().map(CollaborationViews::follow).toList(), page.nextCursor()));
return (new CollaborationFollowerListV1(CollaborationViews.follows(page.items()), page.nextCursor()));
}
}
@@ -66,7 +66,7 @@ public class ListMyAccessRequestsSpec extends CollaborationJsonSpec
{
var values = new com.kingsrook.qbits.collaboration.sharing.AccessRequestService().mine();
var page = page(input, values);
return (new CollaborationWorkSchemas.CollaborationAccessRequestListV1(page.items().stream().map(CollaborationViews::accessRequest).toList(), page.nextCursor()));
return (new CollaborationWorkSchemas.CollaborationAccessRequestListV1(CollaborationViews.accessRequests(page.items()), page.nextCursor()));
}
}
@@ -65,7 +65,7 @@ public class ListMyTasksSpec extends CollaborationJsonSpec
protected Object handle(JsonInput input) throws Exception
{
var page = new com.kingsrook.qbits.collaboration.tasks.TasksService().mine(input.getQueryParam("status"), limit(input), input.getQueryParam("cursor"));
return (new CollaborationWorkSchemas.CollaborationTaskListV1(page.tasks().stream().map(CollaborationViews::task).toList(), page.nextCursor()));
return (new CollaborationWorkSchemas.CollaborationTaskListV1(CollaborationViews.tasks(page.tasks()), page.nextCursor()));
}
}
@@ -70,7 +70,7 @@ public class ListTasksSpec extends CollaborationJsonSpec
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("includeDone must be true or false"));
}
var page = page(input, new com.kingsrook.qbits.collaboration.tasks.TasksService().list(table(input), pk(input), Boolean.valueOf(includeDone)));
return (new CollaborationWorkSchemas.CollaborationTaskListV1(page.items().stream().map(CollaborationViews::task).toList(), page.nextCursor()));
return (new CollaborationWorkSchemas.CollaborationTaskListV1(CollaborationViews.tasks(page.items()), page.nextCursor()));
}
}
@@ -24,15 +24,22 @@ package com.kingsrook.qbits.collaboration.app;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
@@ -41,27 +48,44 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOp
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData.Statistic;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
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.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** The Collaboration app and its home. Counts use QQQ actions with the current
** QContext session, preserving permissions and record security.
** The Collaboration app and its home, built from standard QQQ widgets: a KPI
** block and three full-width tables (tags, custom fields, who's here). Counts
** use QQQ actions with the current QContext session, so permissions and record
** security apply, and every block carries a real empty-state message. No colors
** are specified, so the frontend theme (including dark mode) decides.
*******************************************************************************/
public final class CollaborationHome
{
public static final String HOME_WIDGET = "collaborationHome";
public static final String KPI_WIDGET = "collaborationKpis";
public static final String TAGS_WIDGET = "collaborationTags";
public static final String FIELDS_WIDGET = "collaborationCustomFields";
public static final String PRESENCE_WIDGET = "collaborationWhosHere";
public static final List<String> HOME_WIDGETS = List.of(KPI_WIDGET, TAGS_WIDGET, FIELDS_WIDGET, PRESENCE_WIDGET);
public static final String NOBODY_HERE = "Nobody else is here right now.";
public static final String NO_TAGS = "No tags yet. Tags appear here once someone creates one on a record.";
public static final String NO_FIELDS = "No custom fields yet. Define one under Custom fields.";
public static final String PRESENCE_ADMIN_ONLY = "Who's here is visible to collaboration administrators.";
@@ -81,7 +105,7 @@ public final class CollaborationHome
{
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel("Collaboration").withIcon(new QIcon("forum"))
.withPermissionRules(CollaborationPermissions.permissionRules())
.withWidgets(List.of(HOME_WIDGET))
.withWidgets(HOME_WIDGETS)
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
app.setParentAppName(config.getParentAppName());
@@ -91,141 +115,235 @@ public final class CollaborationHome
/*******************************************************************************
** Full-width HTML widget, following the reference Storage home renderer.
** The four full-width home widgets, in display order.
*******************************************************************************/
public static QWidgetMetaData widget()
public static List<QWidgetMetaData> widgets()
{
return (new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false)
return (List.of(
widget(KPI_WIDGET, "Overview", WidgetType.MULTI_STATISTICS, KpiRenderer.class),
widget(TAGS_WIDGET, "Tags", WidgetType.TABLE, TagsRenderer.class),
widget(FIELDS_WIDGET, "Custom fields", WidgetType.TABLE, FieldsRenderer.class),
widget(PRESENCE_WIDGET, "Who's here", WidgetType.TABLE, PresenceRenderer.class)));
}
/*******************************************************************************
** One full-width card-less widget guarded by the shared permission rules.
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer)
{
return (new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType())
.withCodeReference(new QCodeReference(renderer)).withGridColumns(12).withIsCard(false)
.withPermissionRules(CollaborationPermissions.permissionRules()));
}
/*******************************************************************************
** Show persisted enabled overrides by scope type, and recent directory use.
** These are settings counts, not a claim about resolved host-table features.
** The KPI groups: conversations, work, files and custom fields.
*******************************************************************************/
public static String render(Instant now) throws QException
public static MultiStatisticsData kpis(Instant now) throws QException
{
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
StringBuilder html = new StringBuilder("<div style=\"display:flex;flex-direction:column;gap:1rem;width:100%;overflow-wrap:anywhere;\"><section><h3>Feature settings</h3>");
long settings = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter());
if(settings == 0)
{
html.append("<p>No feature settings yet: features follow the code defaults</p>");
}
else
{
html.append("<p>Enabled feature settings per scope:</p><ul>");
for(String scope : List.of("INSTANCE", "APP", "TABLE"))
{
long enabled = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter()
.withCriteria(new QFilterCriteria("scopeType", QCriteriaOperator.EQUALS, scope))
.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)));
html.append("<li>").append(scope).append(": ").append(enabled).append("</li>");
}
html.append("</ul><p>Feature settings take effect after saving.</p>");
}
long people = count(CollaborationTables.USER, new QQueryFilter());
long recent = count(CollaborationTables.USER, new QQueryFilter()
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS)))
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)));
html.append("</section><section><h3>People</h3><p>People known: ").append(people)
.append("</p><p>Active in the last 7 days: ").append(recent).append("</p>");
if(people == 0)
{
html.append("<p>No people yet: the directory will be filled by collaboration calls when the feature services are installed.</p>");
}
html.append("</section>");
personalSummary(html, CollaborationTables.FAVORITE, "Favorites");
personalSummary(html, CollaborationTables.RECENT, "Recently viewed");
attachmentSummary(html);
tagSummary(html);
fieldSummary(html);
html.append("<section><h3>Open access requests</h3><p>").append(count(CollaborationTables.ACCESS_REQUEST,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
html.append("<section><h3>Open tasks</h3><p>").append(count(CollaborationTables.TASK,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
html.append("<section><h3>Overdue tasks</h3><p>").append(count(CollaborationTables.TASK,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN").withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now))).append("</p></section>");
html.append("<section><h3>Shares this week</h3><p>").append(count(CollaborationTables.SHARE,
new QQueryFilter().withCriteria("sentAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
.withCriteria("sentAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))).append("</p></section>");
html.append("<section><h3>Comments this week</h3><p>").append(count(CollaborationTables.COMMENT, new QQueryFilter()
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
.withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("</p></section>");
html.append("<section><h3>Open threads</h3><p>").append(count(CollaborationTables.COMMENT, new QQueryFilter()
.withCriteria("threadId", QCriteriaOperator.IS_BLANK).withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK)
.withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("</p></section>");
if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
{
html.append("<section><h3>Followers</h3><p>").append(count(CollaborationTables.FOLLOW, new QQueryFilter())).append("</p></section>");
}
html.append("<p>Conversation totals are approximate where parent record locks require post-filtering.</p>");
return (html.append("</div>").toString());
}
/*******************************************************************************
** Caller-filtered vocabulary totals and five most used active tags.
*******************************************************************************/
private static void tagSummary(StringBuilder html) throws QException
{
QQueryFilter active = new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK);
html.append("<section><h3>Tags</h3><p>Vocabulary size: ").append(count(CollaborationTables.TAG, active))
.append("</p><p>Pending suggestions: ").append(count(CollaborationTables.TAG, new QQueryFilter()
.withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK).withCriteria("approved", QCriteriaOperator.EQUALS, false)))
.append("</p><ul>");
com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput input = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(CollaborationTables.TAG)
.withFilter(active.withCriteria("approved", QCriteriaOperator.EQUALS, true).withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(5));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
for(com.kingsrook.qqq.backend.core.model.data.QRecord tag : new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(input).getRecords())
{
html.append("<li>").append(tag.getValueString("label").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
.append(": ").append(tag.getValueInteger("usageCount")).append("</li>");
}
html.append("</ul></section>");
}
/*******************************************************************************
** Real table aggregates, restricted to readable parent tables. Parent locks
** are post-filtered on rows, so these aggregate totals are approximate.
*******************************************************************************/
private static void attachmentSummary(StringBuilder html) throws QException
{
Aggregate count = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
Instant weekAgo = now.minus(7, ChronoUnit.DAYS);
int commentsThisWeek = count(CollaborationTables.COMMENT, new QQueryFilter()
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, weekAgo)
.withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK));
int openThreads = count(CollaborationTables.COMMENT, new QQueryFilter().withCriteria("threadId", QCriteriaOperator.IS_BLANK)
.withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK));
int openTasks = count(CollaborationTables.TASK, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"));
int overdue = count(CollaborationTables.TASK, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN")
.withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now));
int openRequests = count(CollaborationTables.ACCESS_REQUEST, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"));
int activeFields = count(CollaborationTables.FIELD_DEFINITION, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
Aggregate files = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
Aggregate bytes = new Aggregate().withFieldName("sizeBytes").withOperator(AggregateOperator.SUM);
AggregateInput input = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(count, bytes));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<AggregateResult> results = new AggregateAction().execute(input).getResults();
Object rowCount = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(count);
Object totalBytes = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(bytes);
html.append("<section><h3>Attachments</h3><p>Attachments: ").append(rowCount)
.append("</p><p>Total size (bytes): ").append(totalBytes == null ? 0 : totalBytes)
.append("</p><p>Approximate totals: parent record locks are applied when browsing attachments.</p>");
if("0".equals(String.valueOf(rowCount)))
{
html.append("<p>No attachments yet.</p>");
}
html.append("</section>");
AggregateInput aggregateInput = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(files, bytes));
aggregateInput.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<AggregateResult> results = new AggregateAction().execute(aggregateInput).getResults();
int attachments = results.isEmpty() ? 0 : numberOf(results.getFirst().getAggregateValue(files)).intValue();
long totalBytes = results.isEmpty() ? 0 : numberOf(results.getFirst().getAggregateValue(bytes)).longValue();
String fileSubheader = attachments == 0 ? "No attachments yet" : humanSize(totalBytes) + " in total";
return (new MultiStatisticsData("Collaboration", List.of(
new StatisticsGroupData("forum", null, "Conversations", "Last 7 days and open", List.of(
new Statistic("Comments this week", commentsThisWeek, path(CollaborationTables.COMMENT)),
new Statistic("Open threads", openThreads, path(CollaborationTables.COMMENT)))),
new StatisticsGroupData("task_alt", null, "Work", openTasks == 0 && openRequests == 0 ? "Nothing waiting" : "Waiting on someone", List.of(
new Statistic("Open tasks", openTasks, path(CollaborationTables.TASK)),
new Statistic("Overdue tasks", overdue, path(CollaborationTables.TASK)),
new Statistic("Open access requests", openRequests, path(CollaborationTables.ACCESS_REQUEST)))),
new StatisticsGroupData("attach_file", null, "Attachments", fileSubheader, List.of(
new Statistic("Attachments", attachments, path(CollaborationTables.ATTACHMENT)))),
new StatisticsGroupData("tune", null, "Custom fields", activeFields == 0 ? "None active yet" : "Defined by administrators", List.of(
new Statistic("Active custom fields", activeFields, path(CollaborationTables.FIELD_DEFINITION)))))));
}
/*******************************************************************************
** Aggregate rows and distinct owners without loading high-volume personal data.
** Approved and pending tags, most used first: label, table label, records.
*******************************************************************************/
private static void personalSummary(StringBuilder html, String table, String label) throws QException
public static TableData tags() throws QException
{
Aggregate rows = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
Aggregate users = new Aggregate().withFieldName("userId").withOperator(AggregateOperator.COUNT_DISTINCT);
AggregateInput input = new AggregateInput(table).withAggregates(List.of(rows, users));
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
QueryInput input = new QueryInput(CollaborationTables.TAG).withFilter(new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK)
.withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(200));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<AggregateResult> results = new AggregateAction().execute(input).getResults();
html.append("<section><h3>").append(label).append("</h3><p>Rows: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(rows))
.append("</p><p>Distinct users: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(users)).append("</p></section>");
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord tag : new QueryAction().execute(input).getRecords())
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("label", tag.getValueString("label"));
row.put("table", tableLabel(tag.getValueString("tableName")));
row.put("records", tag.getValueInteger("usageCount") == null ? 0 : tag.getValueInteger("usageCount"));
rows.add(row);
}
return (table("Tags", NO_TAGS, rows, column("Label", "label", "3fr", "left"), column("Table", "table", "2fr", "left"), column("Records", "records", "1fr", "right")));
}
/*******************************************************************************
** Custom field definitions: name, table, status and index state.
*******************************************************************************/
public static TableData fields() throws QException
{
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
QueryInput input = new QueryInput(CollaborationTables.FIELD_DEFINITION).withFilter(new QQueryFilter()
.withOrderBy(new QFilterOrderBy("tableName")).withOrderBy(new QFilterOrderBy("name")).withLimit(200));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord definition : new QueryAction().execute(input).getRecords())
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", definition.getValueString("name"));
row.put("table", tableLabel(definition.getValueString("tableName")));
row.put("status", friendly(definition.getValueString("status")));
row.put("indexState", friendly(definition.getValueString("indexState")));
rows.add(row);
}
return (table("Custom fields", NO_FIELDS, rows, column("Name", "name", "3fr", "left"), column("Table", "table", "2fr", "left"),
column("Status", "status", "1fr", "left"), column("Index state", "indexState", "1fr", "left")));
}
/*******************************************************************************
** Live viewer counts per table. Only administrators see them (they cover
** records the viewer may not be able to open); others get an explanation.
*******************************************************************************/
public static TableData presence() throws QException
{
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
boolean admin = QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN);
List<Map<String, Object>> rows = new ArrayList<>();
if(admin)
{
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("table", tableLabel(table));
row.put("viewers", viewers);
rows.add(row);
});
}
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "3fr", "left"), column("People", "viewers", "1fr", "right")));
}
/*******************************************************************************
** Assemble a table widget whose empty state is a sentence, not a blank grid.
*******************************************************************************/
private static TableData table(String label, String emptyMessage, List<Map<String, Object>> rows, TableData.Column... columns)
{
return (new TableData(label, List.of(columns), rows).withNoRowsFoundHTML(emptyMessage).withRowsPerPage(10));
}
/*******************************************************************************
** A plain column; the frontend styles it.
*******************************************************************************/
private static TableData.Column column(String header, String accessor, String width, String align)
{
return (new TableData.Column("default", header, accessor, width, align));
}
/*******************************************************************************
** A table's display label, "All tables" when there is none (global).
*******************************************************************************/
private static String tableLabel(String name)
{
if(name == null || name.isBlank())
{
return ("All tables");
}
QTableMetaData table = QContext.getQInstance().getTable(name);
return (table == null || table.getLabel() == null ? name : table.getLabel());
}
/*******************************************************************************
** ACTIVE becomes Active; a missing value becomes a dash.
*******************************************************************************/
private static String friendly(String value)
{
if(value == null || value.isBlank())
{
return ("-");
}
String lower = value.toLowerCase(Locale.ROOT).replace('_', ' ');
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
}
/*******************************************************************************
** Binary sizes with one decimal, like 4.7 KB; whole bytes below 1 KB.
*******************************************************************************/
public static String humanSize(long bytes)
{
if(bytes < 1024)
{
return (bytes + " B");
}
String[] units = { "KB", "MB", "GB", "TB", "PB" };
double value = bytes;
int unit = -1;
while(value >= 1024 && unit < units.length - 1)
{
value /= 1024;
unit++;
}
return (String.format(Locale.ROOT, "%.1f %s", value, units[unit]));
}
/*******************************************************************************
** Aggregate values come back as any numeric type, or null for empty sets.
*******************************************************************************/
private static Number numberOf(Object value)
{
return (value instanceof Number number ? number : 0);
}
/*******************************************************************************
** The path of a Collaboration table page, under the parent app when configured.
*******************************************************************************/
private static String path(String table)
{
CollaborationQBitConfig config = CollaborationRecords.config();
String prefix = config.getParentAppName() == null ? "/" + config.getAppName() : "/" + config.getParentAppName() + "/" + config.getAppName();
return (prefix + "/" + table);
}
@@ -233,7 +351,7 @@ public final class CollaborationHome
/*******************************************************************************
** Count without materializing rows or substituting a privileged session.
*******************************************************************************/
private static long count(String table, QQueryFilter filter) throws QException
private static int count(String table, QQueryFilter filter) throws QException
{
CountInput input = new CountInput(table).withFilter(filter);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
@@ -243,38 +361,9 @@ public final class CollaborationHome
/*******************************************************************************
** Real definition status and index totals, plus ephemeral admin-only counts.
** KPI renderer instantiated by QQQ.
*******************************************************************************/
private static void fieldSummary(StringBuilder html) throws QException
{
html.append("<section><h3>Custom fields</h3><ul>");
for(String status : List.of("DRAFT", "ACTIVE", "ARCHIVED"))
{
html.append("<li>").append(status).append(": ").append(count(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, status))).append("</li>");
}
for(String state : List.of("NONE", "BUILDING", "READY", "FAILED"))
{
html.append("<li>Index ").append(state).append(": ").append(count(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("indexState", QCriteriaOperator.EQUALS, state))).append("</li>");
}
html.append("</ul><p>Definitions with storage or index messages: ").append(count(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("statusMessage", QCriteriaOperator.IS_NOT_BLANK))).append(". See Custom Fields for details.</p></section>");
if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
{
html.append("<section><h3>Who's here</h3><ul>");
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, count) ->
html.append("<li>").append(table.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")).append(": ").append(count).append("</li>"));
html.append("</ul></section>");
}
}
/*******************************************************************************
** Widget renderer instantiated by QQQ.
*******************************************************************************/
public static class HomeRenderer extends AbstractWidgetRenderer
public static class KpiRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session and time.
@@ -282,7 +371,58 @@ public final class CollaborationHome
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(new RawHTML(null, CollaborationHome.render(Instant.now()))));
return (new RenderWidgetOutput(kpis(Instant.now())));
}
}
/*******************************************************************************
** Tags renderer instantiated by QQQ.
*******************************************************************************/
public static class TagsRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(tags()));
}
}
/*******************************************************************************
** Custom fields renderer instantiated by QQQ.
*******************************************************************************/
public static class FieldsRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(fields()));
}
}
/*******************************************************************************
** Presence renderer instantiated by QQQ.
*******************************************************************************/
public static class PresenceRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render using the current session.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(presence()));
}
}
}
@@ -79,7 +79,7 @@ public class AssignmentService
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)));
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)).withEvent(row.getValue("id"), PersonalRecords.currentUserId()));
}
}
return (row);
@@ -45,14 +45,13 @@ public class AttachmentStorage
}
/*******************************************************************************
** The bucket's own upload cap, when object storage exposes it through its api
** package. It does not yet (requested from qbit-object-storage), so this
** returns Long.MAX_VALUE: collaboration enforces its configured per-table cap,
** and object storage still enforces the bucket's cap when the object is put.
** The bucket's own upload cap, from object storage's api package; Long.MAX_VALUE
** when the bucket has no limit. Collaboration also enforces its per-table cap.
*******************************************************************************/
public long maxBytes(String name)
{
return (Long.MAX_VALUE);
Long max = bucket(name).maxUploadBytes();
return (max == null ? Long.MAX_VALUE : max);
}
/*******************************************************************************
@@ -318,7 +318,8 @@ public class CommentsService
private void notify(QRecord row, String user, CollaborationNotification.Category category, String title, Integer mentionId) throws QException
{
Notifications.send(new CollaborationNotification(user, title, row.getValueString("body"), category,
row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId"))),
row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId")))
.withEvent(mentionId == null ? row.getValue("id") : mentionId, PersonalRecords.currentUserId()),
mentionId == null ? null : () -> CollaborationRecords.update(CollaborationTables.MENTION,
new QRecord().withValue("id", mentionId).withValue("notifiedAt", Instant.now())));
}
@@ -0,0 +1,257 @@
/*
* 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.email;
import java.util.List;
import java.util.function.Function;
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.CollaborationNotifier;
import com.kingsrook.qbits.collaboration.people.PeopleService;
import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.template.EmailTemplateDefinition;
import com.kingsrook.qbits.email.template.EmailTemplates;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Delivers mention, share and task notifications through qbit-email, one
** NOTIFICATION message per recipient. This is the only class that references
** qbit-email; Notifications reaches it only after checking that the email qbit
** is loadable and produced in the instance.
**
** Emails never carry comment bodies, task titles or notes (they may hold PHI):
** they say who did what on which record, with a link. The record label is
** included only when the recipient may read the record and every label field is
** fully accessible; otherwise a generic label is used.
*******************************************************************************/
public final class EmailCollaborationNotifier implements CollaborationNotifier
{
private static final QLogger LOG = QLogger.getLogger(EmailCollaborationNotifier.class);
public static final String OWNER_QBIT = "qbit-collaboration";
public static final String MENTION_TEMPLATE = "collaboration_mention";
public static final String SHARE_TEMPLATE = "collaboration_share";
public static final String TASK_REMINDER_TEMPLATE = "collaboration_task_reminder";
private static final String SAMPLE = "{\"actorName\":\"Alex Example\",\"recordLabel\":\"Order 1042\",\"link\":\"https://example.invalid/app/orders/1042\"}";
private static final String OPEN_LINK = "{{#if link}}[Open the record]({{link}}){.button}{{/if}}";
/*******************************************************************************
** True when the email qbit is produced in the given instance.
*******************************************************************************/
public static boolean isAvailable(QInstance qInstance)
{
return (EmailRuntime.config(qInstance) != null);
}
/*******************************************************************************
** Enqueue the email for a mention, share or task notification; follow digests
** have no email template. Failures are logged by user id and never thrown, so
** a mail problem cannot fail the collaboration action.
*******************************************************************************/
@Override
public void notify(CollaborationNotification n)
{
EmailTemplateDefinition template = templateFor(n.category());
if(template == null)
{
return;
}
try
{
String address = address(n.recipientUserId());
if(!StringUtils.hasContent(address))
{
LOG.info("Collaboration email skipped: no address for recipient", logPair("userId", n.recipientUserId()));
return;
}
EmailTemplates.ensure(template);
String eventId = n.sourceId() != null ? n.sourceId() : n.sourceRecordId();
EmailRequest request = new EmailRequest().withTemplateName(template.getName()).withCategory(EmailCategory.NOTIFICATION).withTo(address)
.withIdempotencyKey("collaboration:" + kind(n.category()) + ":" + eventId + ":" + n.recipientUserId())
.withSender(OWNER_QBIT, "notify-" + kind(n.category()))
.withVariable("actorName", actorName(n.actorUserId())).withVariable("recordLabel", recordLabel(n));
if(n.sourceTableName() != null && n.sourceRecordId() != null)
{
request.withLinkedRecord(n.sourceTableName(), n.sourceRecordId());
}
if(n.link() != null && n.link().startsWith("http"))
{
request.withVariable("link", n.link());
}
EmailSendResult result = EmailService.send(request);
if(result.status() != EmailSendResult.Status.QUEUED)
{
LOG.info("Collaboration email not queued", logPair("userId", n.recipientUserId()), logPair("status", result.status()),
logPair("messageId", result.messageId()));
}
}
catch(QException | RuntimeException e)
{
LOG.warn("Collaboration email failed", e, logPair("userId", n.recipientUserId()), logPair("category", n.category()));
}
}
/*******************************************************************************
** The recipient address from the configured resolver, else from the
** collaboration people directory.
*******************************************************************************/
private String address(String userId) throws QException
{
Function<String, String> resolver = CollaborationRecords.config().getRecipientEmailResolver();
if(resolver != null)
{
return (resolver.apply(userId));
}
List<QRecord> rows = CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, userId));
return (rows.isEmpty() ? null : rows.getFirst().getValueString("email"));
}
/*******************************************************************************
** The person's display name, or a generic word when the actor is unknown.
*******************************************************************************/
private String actorName(String actorUserId) throws QException
{
return (actorUserId == null ? "Someone" : new PeopleService().displayName(actorUserId));
}
/*******************************************************************************
** The record label when the recipient may see it, else a generic phrase.
*******************************************************************************/
private String recordLabel(CollaborationNotification n) throws QException
{
if(n.sourceTableName() == null)
{
return ("a record");
}
return (new CollaborationRecordAccessChecker().recordLabel(n.recipientUserId(), n.sourceTableName(), n.sourceRecordId())
.orElse("a record in " + QContext.getQInstance().getTable(n.sourceTableName()).getLabel()));
}
/*******************************************************************************
** Idempotency kind for a category.
*******************************************************************************/
private static String kind(CollaborationNotification.Category category)
{
return (switch(category)
{
case MENTION -> "mention";
case SHARE -> "share";
default -> "task";
});
}
/*******************************************************************************
** The shipped template for a category, or null when none exists.
*******************************************************************************/
static EmailTemplateDefinition templateFor(CollaborationNotification.Category category)
{
return (switch(category)
{
case MENTION -> mentionTemplate();
case SHARE -> shareTemplate();
case TASK -> taskReminderTemplate();
default -> null;
});
}
/*******************************************************************************
** Shared definition: variable schema and ownership. Existing templates, and
** people's edits, are never touched. Labels are CONFIDENTIAL, not PII or PHI:
** the template needs no PHI approval because labels are passed only when the
** record label fields are fully accessible.
*******************************************************************************/
private static EmailTemplateDefinition definition(String name, String label, String subject, String body)
{
return (new EmailTemplateDefinition().withName(name).withLabel(label).withCategory(EmailCategory.NOTIFICATION).withKind("MARKDOWN")
.withOwnerQbit(OWNER_QBIT).withSubject(subject).withBody(body)
.withVariable("actorName", "Person who acted", "STRING", true, "CONFIDENTIAL")
.withVariable("recordLabel", "Record label", "STRING", true, "CONFIDENTIAL")
.withVariable("link", "Link to the record", "URL", false, "PUBLIC")
.withSampleVariables(SAMPLE));
}
/*******************************************************************************
** Someone mentioned the recipient.
*******************************************************************************/
static EmailTemplateDefinition mentionTemplate()
{
return (definition(MENTION_TEMPLATE, "Collaboration mention", "{{actorName}} mentioned you on {{recordLabel}}",
"**{{actorName}}** mentioned you on {{recordLabel}}.\n\n" + OPEN_LINK));
}
/*******************************************************************************
** Someone shared a record with the recipient.
*******************************************************************************/
static EmailTemplateDefinition shareTemplate()
{
return (definition(SHARE_TEMPLATE, "Collaboration share", "{{actorName}} shared {{recordLabel}} with you",
"**{{actorName}}** shared {{recordLabel}} with you.\n\n" + OPEN_LINK));
}
/*******************************************************************************
** A task assigned to the recipient was assigned, is due, or is overdue.
*******************************************************************************/
static EmailTemplateDefinition taskReminderTemplate()
{
return (definition(TASK_REMINDER_TEMPLATE, "Collaboration task reminder", "A task on {{recordLabel}} needs your attention",
"A task assigned to you on {{recordLabel}} needs your attention.\n\n" + OPEN_LINK));
}
}
@@ -132,7 +132,7 @@ public final class CollaborationTables
enumSource(FEATURE, "ATTACHMENTS", "TAGS", "CUSTOM_FIELDS", "COMMENTS", "PRESENCE", "SHARING",
"FAVORITES", "FOLLOW", "TIMELINE", "RECENTS", "TASKS", "ASSIGNMENT"),
enumSource(PERMISSION_MODE, "EDIT_OR_GRANTED", "GRANTED_ONLY"),
QPossibleValueSource.newForTable(FEATURE_SETTING), QPossibleValueSource.newForTable(USER)));
QPossibleValueSource.newForTable(FEATURE_SETTING), QPossibleValueSource.newForTable(USER).withOverrideIdField("userId")));
}
@@ -239,7 +239,7 @@ public final class CollaborationTables
.withRecordSecurityLock(CollaborationSecurity.ownerLock("userId"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE))
.withRecordLabelFormatAndFields("%s", "label")
.withField(string("userId", "User ID", 255).withIsRequired(true))
.withField(user("userId", "User ID").withIsRequired(true))
.withField(string("targetType", "Target Type", 20).withIsRequired(true).withPossibleValueSourceName(TARGET_TYPE))
.withField(string("targetName", "Target Name", 255).withIsRequired(true))
.withField(string("recordId", "Record ID", 255))
@@ -251,6 +251,15 @@ public final class CollaborationTables
/*******************************************************************************
** A user-id string shown by display name wherever the table is browsed.
*******************************************************************************/
private static QFieldMetaData user(String name, String label)
{
return (string(name, label, 255).withPossibleValueSourceName(USER));
}
/*******************************************************************************
** A bounded string field.
*******************************************************************************/
@@ -280,7 +289,7 @@ public final class CollaborationTables
.withField(string("storageKey", "Storage Key", 1024).withIsRequired(true))
.withField(string("thumbnailKey", "Thumbnail Key", 1024))
.withField(new QFieldMetaData("description", QFieldType.TEXT).withLabel("Description"))
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
.withField(user("createdByUserId", "Created By").withIsRequired(true))
.withField(new QFieldMetaData("parentDeleted", QFieldType.BOOLEAN).withLabel("Parent Deleted").withIsRequired(true).withDefaultValue(false))
.withVirtualField(new QVirtualFieldMetaData("previewUrl", QFieldType.STRING).withLabel("Preview").withIsEditable(false).withIsQuerySelectable(true)
.withFieldAdornment(AdornmentType.FilePreviewValues.newFieldAdornment()
@@ -322,28 +331,28 @@ public final class CollaborationTables
.withField(new QFieldMetaData("body", QFieldType.TEXT).withMaxLength(20000))
// TODO: add .withDependsOn("tableName") when QQQ core ships contingent dependsOn (docs/CONTINGENT-POSSIBLE-VALUES.md)
.withField(string("fieldName", "Field", 128).withPossibleValueSourceName(RECORD_FIELD_NAME))
.withField(string("authorUserId", "Author", 255).withIsRequired(true))
.withField(user("authorUserId", "Author").withIsRequired(true))
.withField(new QFieldMetaData("lastActivityAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(new QFieldMetaData("editedAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("resolvedAt", QFieldType.DATE_TIME))
.withField(string("resolvedByUserId", "Resolved By", 255))
.withField(user("resolvedByUserId", "Resolved By"))
.withField(new QFieldMetaData("deletedAt", QFieldType.DATE_TIME))
.withField(string("deletedByUserId", "Deleted By", 255))
.withField(user("deletedByUserId", "Deleted By"))
.withRecordLabelFormatAndFields("Comment %s", "id");
QTableMetaData revisions = referenced(config, COMMENT_REVISION, "Comment History", "history")
.withField(new QFieldMetaData("commentId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(COMMENT))
.withField(new QFieldMetaData("body", QFieldType.TEXT))
.withField(new QFieldMetaData("editedAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(string("editedByUserId", "Edited By", 255));
.withField(user("editedByUserId", "Edited By"));
QTableMetaData mentions = referenced(config, MENTION, "Mentions", "alternate_email")
.withField(new QFieldMetaData("commentId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(COMMENT))
.withField(string("mentionedUserId", "Mentioned User", 255).withIsRequired(true))
.withField(user("mentionedUserId", "Mentioned User").withIsRequired(true))
.withField(new QFieldMetaData("visible", QFieldType.BOOLEAN).withIsRequired(true))
.withField(new QFieldMetaData("readAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("notifiedAt", QFieldType.DATE_TIME))
.withUniqueKey(new UniqueKey("commentId", "mentionedUserId"));
QTableMetaData follows = referenced(config, FOLLOW, "Followers", "notifications")
.withField(string("userId", "User", 255).withIsRequired(true))
.withField(user("userId", "User").withIsRequired(true))
.withField(string("reason", "Reason", 32).withIsRequired(true))
.withField(new QFieldMetaData("notifyOnChange", QFieldType.BOOLEAN).withIsRequired(true))
.withField(new QFieldMetaData("notifyOnComment", QFieldType.BOOLEAN).withIsRequired(true))
@@ -353,13 +362,13 @@ public final class CollaborationTables
.withoutCapabilities(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT,
com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE)
.withField(new QFieldMetaData("at", QFieldType.DATE_TIME).withIsRequired(true))
.withField(string("actorUserId", "Actor", 255))
.withField(user("actorUserId", "Actor"))
.withField(string("type", "Type", 64).withIsRequired(true))
.withField(new QFieldMetaData("summary", QFieldType.TEXT))
.withField(string("refTableName", "Reference Table", 128))
.withField(string("refId", "Reference ID", 64));
QTableMetaData digests = referenced(config, FOLLOW_DIGEST, "Follow Digests", "notifications")
.withField(string("userId", "Recipient", 255).withIsRequired(true))
.withField(user("userId", "Recipient").withIsRequired(true))
.withField(new QFieldMetaData("dueAt", QFieldType.DATE_TIME).withIsRequired(true))
.withField(new QFieldMetaData("changes", QFieldType.TEXT))
.withUniqueKey(new UniqueKey("tableName", "recordId", "userId"));
@@ -411,7 +420,7 @@ public final class CollaborationTables
.withField(string("tableName", "Table", 128))
.withField(new QFieldMetaData("approved", QFieldType.BOOLEAN).withIsRequired(true))
.withField(new QFieldMetaData("description", QFieldType.TEXT))
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
.withField(user("createdByUserId", "Created By").withIsRequired(true))
.withField(new QFieldMetaData("usageCount", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(0))
.withField(new QFieldMetaData("mergedIntoTagId", QFieldType.INTEGER).withPossibleValueSourceName(TAG))
.withUniqueKey(new UniqueKey("tableName", "name"))
@@ -421,7 +430,7 @@ public final class CollaborationTables
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.security.CollaborationRowSecurity.class)))
.withField(new QFieldMetaData("tagId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(TAG))
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
.withField(user("createdByUserId", "Created By").withIsRequired(true))
.withUniqueKey(new UniqueKey("tableName", "recordId", "tagId"))
.withSection(new QFieldSection("details", new QIcon("label"), Tier.T1, List.of("id", "tagId", "createdByUserId")).withLabel("Details"));
for(QTableMetaData table : List.of(tags, links))
@@ -437,8 +446,8 @@ public final class CollaborationTables
private static List<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(user("fromUserId", "From").withIsRequired(true))
.withField(user("toUserId", "To").withIsRequired(true))
.withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000))
.withField(new QFieldMetaData("recipientCouldRead", QFieldType.BOOLEAN))
.withField(new QFieldMetaData("sentAt", QFieldType.DATE_TIME).withIsRequired(true))
@@ -451,29 +460,29 @@ public final class CollaborationTables
.withRecordLabelFormatAndFields("Access request %s", "id")
.withField(string("tableName", "Table", 128).withIsRequired(true))
.withField(string("recordId", "Record ID", 64).withIsRequired(true))
.withField(string("requesterUserId", "Requester", 255).withIsRequired(true))
.withField(user("requesterUserId", "Requester").withIsRequired(true))
.withField(new QFieldMetaData("shareId", QFieldType.INTEGER))
.withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000))
.withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_access_request_status"))
.withField(string("handledByUserId", "Handled By", 255))
.withField(user("handledByUserId", "Handled By"))
.withField(new QFieldMetaData("handledAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("note", QFieldType.TEXT).withMaxLength(2000));
QTableMetaData tasks = referenced(config, TASK, "Tasks", "task_alt")
.withRecordLabelFormatAndFields("%s", "title").withSearchFields(List.of("title"))
.withField(string("title", "Title", 200).withIsRequired(true))
.withField(new QFieldMetaData("notes", QFieldType.TEXT))
.withField(string("assigneeUserId", "Assignee", 255))
.withField(user("assigneeUserId", "Assignee"))
.withField(new QFieldMetaData("dueAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("remindAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("remindedAt", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("overdueNotifiedAt", QFieldType.DATE_TIME))
.withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_task_status"))
.withField(new QFieldMetaData("completedAt", QFieldType.DATE_TIME))
.withField(string("completedByUserId", "Completed By", 255))
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true));
.withField(user("completedByUserId", "Completed By"))
.withField(user("createdByUserId", "Created By").withIsRequired(true));
QTableMetaData assignments = referenced(config, RECORD_ASSIGNMENT, "Record Owners", "assignment_ind")
.withField(string("ownerUserId", "Owner", 255))
.withField(string("assigneeUserId", "Assignee", 255))
.withField(user("ownerUserId", "Owner"))
.withField(user("assigneeUserId", "Assignee"))
.withUniqueKey(new UniqueKey("tableName", "recordId"));
List<QTableMetaData> tables = List.of(shares, requests, tasks, assignments);
for(QTableMetaData table : tables)
@@ -519,7 +528,7 @@ public final class CollaborationTables
.withField(string("storage", "Storage", 16).withIsEditable(false))
.withField(new QFieldMetaData("statusMessage", QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData("indexDdl", QFieldType.TEXT).withLabel("Index DDL").withIsEditable(false))
.withField(string("createdByUserId", "Created by", 255).withIsEditable(false))
.withField(user("createdByUserId", "Created by").withIsEditable(false))
.withUniqueKey(new UniqueKey("tableName", "name"));
QCodeReference customizer = new QCodeReference(com.kingsrook.qbits.collaboration.customfields.FieldDefinitionCustomizer.class);
definitions.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, customizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, customizer)
@@ -24,10 +24,35 @@ package com.kingsrook.qbits.collaboration.notify;
/*******************************************************************************
** Transport-neutral notification. Body is markdown; link is a record href.
** sourceId identifies the event (mention, share, task or assignment row) so a
** transport can de-duplicate; actorUserId is the person who caused it. Both are
** optional.
*******************************************************************************/
public record CollaborationNotification(String recipientUserId, String title, String body, Category category,
String sourceTableName, String sourceRecordId, String link)
String sourceTableName, String sourceRecordId, String link, String sourceId, String actorUserId)
{
/*******************************************************************************
** Notification without an event id or actor.
*******************************************************************************/
public CollaborationNotification(String recipientUserId, String title, String body, Category category,
String sourceTableName, String sourceRecordId, String link)
{
this(recipientUserId, title, body, category, sourceTableName, sourceRecordId, link, null, null);
}
/*******************************************************************************
** Copy carrying the event id and actor.
*******************************************************************************/
public CollaborationNotification withEvent(Object eventId, String actor)
{
return (new CollaborationNotification(recipientUserId, title, body, category, sourceTableName, sourceRecordId, link,
eventId == null ? null : String.valueOf(eventId), actor));
}
/*******************************************************************************
** Categories supported by the collaboration notification boundary.
*******************************************************************************/
@@ -22,11 +22,13 @@
package com.kingsrook.qbits.collaboration.notify;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.email.EmailCollaborationNotifier;
import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeVoidVoidMethod;
/*******************************************************************************
@@ -99,7 +101,7 @@ public final class Notifications
{
if(!checkParent || new CollaborationRecordAccessChecker().canRead(notification.recipientUserId(), notification.sourceTableName(), notification.sourceRecordId()).orElse(false))
{
CollaborationNotifier notifier = QCodeLoader.getAdHoc(CollaborationNotifier.class, CollaborationRecords.config().getNotifier());
CollaborationNotifier notifier = notifier();
notifier.notify(notification);
if(!(notifier instanceof NoOpNotifier))
{
@@ -121,6 +123,41 @@ public final class Notifications
}
}
/*******************************************************************************
** The configured notifier; with none configured, email through qbit-email when
** it is on the classpath and produced in this instance, else no transport.
*******************************************************************************/
private static CollaborationNotifier notifier() throws QException
{
QCodeReference configured = CollaborationRecords.config().getNotifier();
if(configured != null)
{
return (QCodeLoader.getAdHoc(CollaborationNotifier.class, configured));
}
if(emailAvailable())
{
return (new EmailCollaborationNotifier());
}
return (new NoOpNotifier());
}
/*******************************************************************************
** qbit-email is optional: the adapter is touched only after the class check
** passes and the email qbit is produced in the current instance.
*******************************************************************************/
private static boolean emailAvailable()
{
try
{
Class.forName("com.kingsrook.qbits.email.outbox.EmailService");
return (EmailCollaborationNotifier.isAvailable(QContext.getQInstance()));
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
/*******************************************************************************
** Relative canonical frontend link with separately encoded path segments.
*******************************************************************************/
@@ -109,10 +109,11 @@ public class PeopleService
/*******************************************************************************
** Rank participants before limiting, with bounded backend queries for both groups.
** The caller never appears in the results: nobody shares with or mentions themself.
*******************************************************************************/
public List<PersonEntry> search(String q, Integer limit, Set<String> preferredUserIds) throws QException
{
PersonalRecords.currentUserId();
String me = PersonalRecords.currentUserId();
if(limit == null || limit < 1 || limit > 100)
{
throw (new QBadRequestException("Limit must be between 1 and 100"));
@@ -120,12 +121,12 @@ public class PeopleService
List<QRecord> rows = new ArrayList<>();
if(!preferredUserIds.isEmpty())
{
rows.addAll(CollaborationRecords.query(CollaborationTables.USER, searchFilter(q, limit)
rows.addAll(CollaborationRecords.query(CollaborationTables.USER, searchFilter(q, limit, me)
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferredUserIds))));
}
if(rows.size() < limit)
{
QQueryFilter other = searchFilter(q, limit - rows.size());
QQueryFilter other = searchFilter(q, limit - rows.size(), me);
if(!preferredUserIds.isEmpty())
{
other.withCriteria("userId", QCriteriaOperator.NOT_IN, new ArrayList<>(preferredUserIds));
@@ -139,9 +140,10 @@ public class PeopleService
/*******************************************************************************
** Keep active and group restrictions outside the display-name/email OR.
*******************************************************************************/
private QQueryFilter searchFilter(String q, Integer limit)
private QQueryFilter searchFilter(String q, Integer limit, String excludedUserId)
{
QQueryFilter filter = new QQueryFilter().withCriteria("active", QCriteriaOperator.EQUALS, true)
.withCriteria("userId", QCriteriaOperator.NOT_EQUALS, excludedUserId)
.withOrderBy(new QFilterOrderBy("displayName")).withOrderBy(new QFilterOrderBy("userId")).withLimit(limit);
if(q != null && !q.isBlank())
{
@@ -161,6 +163,37 @@ public class PeopleService
return (rows.isEmpty() || rows.getFirst().getValueString("displayName") == null ? userId : rows.getFirst().getValueString("displayName"));
}
/*******************************************************************************
** Resolve many display names with one directory query. Unknown ids map to
** themselves, so a caller always has something readable to show.
*******************************************************************************/
public java.util.Map<String, String> displayNames(java.util.Collection<String> userIds) throws QException
{
java.util.Map<String, String> names = new java.util.LinkedHashMap<>();
Set<String> ids = new java.util.LinkedHashSet<>();
for(String id : userIds)
{
if(id != null && !id.isBlank())
{
ids.add(id);
}
}
if(ids.isEmpty())
{
return (names);
}
for(QRecord row : CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(ids))))
{
String name = row.getValueString("displayName");
if(name != null && !name.isBlank())
{
names.put(row.getValueString("userId"), name);
}
}
ids.forEach(id -> names.putIfAbsent(id, id));
return (names);
}
/*******************************************************************************
** Look up one active person with the same signed-in boundary as search.
*******************************************************************************/
@@ -27,10 +27,14 @@ import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
@@ -82,4 +86,59 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
});
return (Optional.of(result.get()));
}
/*******************************************************************************
** The record label as the recipient may see it: empty unless a session for the
** recipient exists, the record is readable, and every label field is fully
** accessible (FieldAccessHelper.isRecordLabelFullAccess, absent in older QQQ
** versions, which means no label).
*******************************************************************************/
public Optional<String> recordLabel(String userId, String table, Serializable recordId) throws QException
{
CollaborationQBitConfig config = CollaborationRecords.config();
if(config.getUserSessionProvider() == null || table == null || recordId == null)
{
return (Optional.empty());
}
Optional<QSession> session = QCodeLoader.getAdHoc(com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface.class, config.getUserSessionProvider()).sessionForUser(userId);
if(session.isEmpty() || session.get().getUser() == null || !userId.equals(session.get().getUser().getIdReference()))
{
return (Optional.empty());
}
AtomicReference<String> result = new AtomicReference<>();
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), session.get()), () ->
{
try
{
ParentRecordAccess.requireVisibleParent(table, recordId);
if(labelFullyAccessible(table))
{
GetInput input = new GetInput(table).withPrimaryKey(recordId).withShouldGenerateDisplayValues(true);
QRecord record = new GetAction().executeForRecord(input);
result.set(record == null ? null : record.getRecordLabel());
}
}
catch(QNotFoundException expected)
{
// Not readable by the recipient: no label.
}
});
return (Optional.ofNullable(result.get()).filter(label -> !label.isBlank()));
}
/*******************************************************************************
** Reflective guard: the helper only exists in QQQ versions with secured data.
*******************************************************************************/
private static boolean labelFullyAccessible(String table)
{
try
{
Class<?> helper = Class.forName("com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper");
return (Boolean.TRUE.equals(helper.getMethod("isRecordLabelFullAccess", QTableMetaData.class).invoke(null, QContext.getQInstance().getTable(table))));
}
catch(ReflectiveOperationException | LinkageError e)
{
return (false);
}
}
}
@@ -98,7 +98,7 @@ public class SharingService
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()));
CollaborationNotification.Category.SHARE, table, id, link.url()).withEvent(row.getValue("id"), from));
new FollowService().ensureFollower(recipient.userId(), table, id, "SHARED");
}
else
@@ -106,7 +106,7 @@ public class SharingService
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")));
+ TargetResolver.encode(table) + "&recordId=" + TargetResolver.encode(id) + "&shareId=" + row.getValueString("id")).withEvent(row.getValue("id"), from));
}
ActivityWriter.write(table, id, "SHARED", "Record shared", CollaborationTables.SHARE, row.getValueString("id"));
result.add(row);
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.collaboration.tags;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.tables.query.CriteriaExpansionContext;
import com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind;
@@ -71,7 +72,7 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac
}
/*******************************************************************************
** Numeric values are picker IDs; strings are exact normalized vocabulary names.
** Numeric values (or numeric strings naming an existing tag) are picker IDs; other strings are exact normalized names.
*******************************************************************************/
private QQueryFilter membership(QTableMetaData table, List<Serializable> values, boolean negate, boolean any) throws QException
{
@@ -90,8 +91,8 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac
{
throw (new QUserFacingException("Tags cannot contain null values"));
}
choices.withCriteria(value instanceof Number ? "id" : "name", QCriteriaOperator.EQUALS,
value instanceof Number ? value : TagsService.normalize(value.toString()));
Integer id = tagId(table, value);
choices.withCriteria(id != null ? "id" : "name", QCriteriaOperator.EQUALS, id != null ? id : TagsService.normalize(value.toString()));
}
tags.withSubFilter(choices);
links.withCriteria("tagId", QCriteriaOperator.IN, new QSubQuery(CollaborationTables.TAG, "id", tags));
@@ -100,6 +101,30 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac
new QSubQuery(CollaborationTables.RECORD_TAG, "recordId", links)));
}
/*******************************************************************************
** A value is a picker id when it is a Number (any JSON numeric type) or a String
** holding an integer that matches an existing tag; otherwise it is a name (null).
*******************************************************************************/
private Integer tagId(QTableMetaData table, Serializable value) throws QException
{
if(value instanceof Number number)
{
return (number.intValue());
}
String text = value.toString().strip();
try
{
int id = Integer.parseInt(text);
boolean exists = !CollaborationRecords.query(CollaborationTables.TAG, TagsService.vocabularyFilter(table.getName())
.withCriteria("id", QCriteriaOperator.EQUALS, id)).isEmpty();
return (exists ? id : null);
}
catch(NumberFormatException e)
{
return (null);
}
}
/*******************************************************************************
** Only live associations on this parent table participate.
*******************************************************************************/
@@ -83,7 +83,7 @@ public class TaskReminders
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)),
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)).withEvent(row.getValue("id") + ":" + dueField, null),
() -> CollaborationRecords.update(CollaborationTables.TASK, new QRecord().withValue("id", row.getValue("id")).withValue(receipt, now)));
}
}
@@ -266,7 +266,8 @@ public class TasksService
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)));
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id))
.withEvent(row.getValue("id"), PersonalRecords.currentUserId()));
}
}
}
@@ -56,6 +56,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
*******************************************************************************/
public class TimelineService
{
private static final int MAX_FIELDS_IN_SUMMARY = 4;
/*******************************************************************************
** Types accepts AUDIT/ACTIVITY or individual collaboration activity type names.
*******************************************************************************/
@@ -154,8 +156,10 @@ public class TimelineService
List<TimelineItem> result = new ArrayList<>();
for(QRecord row : headers)
{
result.add(new TimelineItem("AUDIT", row.getValueLong("id"), row.getValueInstant("timestamp"), null, actors.get(row.getValueInteger("auditUserId")),
row.getValueString("message"), List.copyOf(details.getOrDefault(row.getValueLong("id"), List.of())), AuditsMetaDataProvider.TABLE_NAME_AUDIT, row.getValueString("id")));
String actor = actors.get(row.getValueInteger("auditUserId"));
List<TimelineDetail> rowDetails = List.copyOf(details.getOrDefault(row.getValueLong("id"), List.of()));
result.add(new TimelineItem("AUDIT", row.getValueLong("id"), row.getValueInstant("timestamp"), null, actor,
friendlySummary(row.getValueString("message"), actor, rowDetails, personalized), rowDetails, AuditsMetaDataProvider.TABLE_NAME_AUDIT, row.getValueString("id")));
}
return (result);
}
@@ -165,6 +169,40 @@ public class TimelineService
}
}
/*******************************************************************************
** Core words its audits "Record was Edited" or "Record was Inserted"; the timeline
** says who did what instead: "Edited by Alice Morgan: Status, Total" or "Created by
** Alice Morgan". The detail rows are unchanged. Any other message is kept as written.
*******************************************************************************/
public static String friendlySummary(String message, String actor, List<TimelineDetail> details, QTableMetaData table)
{
String verb = message == null ? null : message.startsWith("Record was Inserted") ? "Created" : message.startsWith("Record was Edited") ? "Edited"
: message.startsWith("Record was Deleted") ? "Deleted" : null;
if(verb == null)
{
return (message);
}
StringBuilder summary = new StringBuilder(verb);
if(actor != null && !actor.isBlank())
{
summary.append(" by ").append(actor);
}
if(verb.equals("Edited"))
{
List<String> labels = details.stream().map(TimelineDetail::fieldName).filter(name -> name != null && !name.isBlank()).distinct()
.map(name -> table != null && table.getFields().containsKey(name) && table.getFields().get(name).getLabel() != null ? table.getFields().get(name).getLabel() : name).toList();
if(!labels.isEmpty())
{
summary.append(": ").append(String.join(", ", labels.subList(0, Math.min(MAX_FIELDS_IN_SUMMARY, labels.size()))));
if(labels.size() > MAX_FIELDS_IN_SUMMARY)
{
summary.append(" and ").append(labels.size() - MAX_FIELDS_IN_SUMMARY).append(" more");
}
}
}
return (summary.toString());
}
/*******************************************************************************
** Explicit enforcement applies to every audit table, independent of host defaults.
*******************************************************************************/
@@ -39,6 +39,10 @@ components:
format: "date-time"
nullable: true
type: "string"
handledByDisplayName:
description: "Display name of the person who handled the request."
nullable: true
type: "string"
id:
description: "id."
nullable: true
@@ -55,6 +59,10 @@ components:
description: "recordId."
nullable: true
type: "string"
requesterDisplayName:
description: "Requester display name."
nullable: true
type: "string"
requesterUserId:
description: "requesterUserId."
nullable: true
@@ -81,6 +89,10 @@ components:
type: "object"
CollaborationAssignmentV1:
properties:
assigneeDisplayName:
description: "Assignee display name."
nullable: true
type: "string"
assigneeUserId:
description: "assigneeUserId."
nullable: true
@@ -89,6 +101,10 @@ components:
description: "id."
nullable: true
type: "integer"
ownerDisplayName:
description: "Owner display name."
nullable: true
type: "string"
ownerUserId:
description: "ownerUserId."
nullable: true
@@ -159,6 +175,10 @@ components:
format: "date-time"
nullable: true
type: "string"
createdByDisplayName:
description: "Uploader display name."
nullable: true
type: "string"
createdByUserId:
description: "Uploader identity."
nullable: true
@@ -258,6 +278,10 @@ components:
format: "date-time"
nullable: true
type: "string"
editedByDisplayName:
description: "Editor display name."
nullable: true
type: "string"
editedByUserId:
description: "Editor identity."
nullable: true
@@ -494,6 +518,10 @@ components:
- "SHARED"
nullable: true
type: "string"
userDisplayName:
description: "Follower display name."
nullable: true
type: "string"
userId:
description: "Follower identity."
nullable: true
@@ -699,6 +727,10 @@ components:
type: "object"
CollaborationShareV1:
properties:
fromDisplayName:
description: "Sender display name."
nullable: true
type: "string"
fromUserId:
description: "fromUserId."
nullable: true
@@ -728,6 +760,10 @@ components:
description: "tableName."
nullable: true
type: "string"
toDisplayName:
description: "Recipient display name."
nullable: true
type: "string"
toUserId:
description: "toUserId."
nullable: true
@@ -904,6 +940,10 @@ components:
type: "object"
CollaborationTaskV1:
properties:
assigneeDisplayName:
description: "Assignee display name."
nullable: true
type: "string"
assigneeUserId:
description: "assigneeUserId."
nullable: true
@@ -913,10 +953,18 @@ components:
format: "date-time"
nullable: true
type: "string"
completedByDisplayName:
description: "Display name of the person who completed the task."
nullable: true
type: "string"
completedByUserId:
description: "completedByUserId."
nullable: true
type: "string"
createdByDisplayName:
description: "Creator display name."
nullable: true
type: "string"
createdByUserId:
description: "createdByUserId."
nullable: true
@@ -32,6 +32,7 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import com.fasterxml.jackson.databind.JsonNode;
@@ -39,6 +40,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -74,7 +77,7 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
MemoryStorage.objects.clear();
MemoryStorage.lastGet = null;
MemoryStorage.fail = false;
config.withAttachmentStorage(new QCodeReference(MemoryStorage.class));
config.withAttachmentStorage(new QCodeReference(MemoryStorage.class)).withUserSessionProvider(new QCodeReference(CarolSessions.class));
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
.withCustomizer(new QCodeReference(TestSessions.class)));
int port;
@@ -166,6 +169,49 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
assertError(400, "POST", "/records/document/1/tasks", "{\"title\":5}", "alice");
}
/*******************************************************************************
** Every payload that carries a user id also carries a display name, resolved in
** one batch per response; ids with no directory entry fall back to themselves.
*******************************************************************************/
@Test
void payloadsCarryDisplayNames() throws Exception
{
assertThat(json(request("GET", "/people/carol", null, "carol"), 200).get("displayName").asText()).isEqualTo("Carol Diaz");
var task = ok("POST", "/records/document/1/tasks", "{\"title\":\"Review\",\"assigneeUserId\":\"carol\"}");
assertThat(task.get("assigneeDisplayName").asText()).isEqualTo("Carol Diaz");
assertThat(task.get("createdByDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(task.get("completedByDisplayName").isNull()).isTrue();
assertThat(ok("GET", "/records/document/1/tasks", null).at("/items/0/assigneeDisplayName").asText()).isEqualTo("Carol Diaz");
assertThat(ok("GET", "/my-work", null).at("/tasks").size()).isZero();
var done = ok("PATCH", "/tasks/" + task.get("id").asInt(), "{\"status\":\"DONE\"}");
assertThat(done.get("completedByDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(json(request("GET", "/tasks/mine?status=DONE", null, "carol"), 200).at("/items/0/createdByDisplayName").asText()).isEqualTo("Alice Morgan");
JsonNode upload = json(upload("alice", "report.txt", "hello", "/records/document/1/attachments"), 200);
assertThat(upload.get("createdByDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(ok("GET", "/records/document/1/attachments", null).at("/items/0/createdByDisplayName").asText()).isEqualTo("Alice Morgan");
JsonNode comment = ok("POST", "/records/document/1/comments", "{\"body\":\"First\"}");
assertThat(comment.get("authorDisplayName").asText()).isEqualTo("Alice Morgan");
ok("PATCH", "/comments/" + comment.get("id").asInt(), "{\"body\":\"Edited\"}");
assertThat(ok("GET", "/comments/" + comment.get("id").asInt() + "/history", null).at("/items/0/editedByDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(ok("GET", "/records/document/1/followers", null).findValuesAsText("userDisplayName")).contains("Alice Morgan");
JsonNode shared = ok("POST", "/records/document/1/shares", "{\"toUserIds\":[\"carol\"],\"message\":\"Look\"}");
assertThat(shared.at("/items/0/fromDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(shared.at("/items/0/toDisplayName").asText()).isEqualTo("Carol Diaz");
JsonNode request = ok("POST", "/access-requests", "{\"tableName\":\"document\",\"recordId\":\"999\",\"message\":\"Please\"}");
assertThat(request.get("requesterDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(request.get("handledByDisplayName").isNull()).isTrue();
assertThat(ok("GET", "/access-requests/mine", null).at("/items/0/requesterDisplayName").asText()).isEqualTo("Alice Morgan");
JsonNode assignment = ok("PUT", "/records/document/1/assignment", "{\"ownerUserId\":\"alice\",\"assigneeUserId\":\"carol\"}");
assertThat(assignment.get("ownerDisplayName").asText()).isEqualTo("Alice Morgan");
assertThat(assignment.get("assigneeDisplayName").asText()).isEqualTo("Carol Diaz");
}
/*******************************************************************************
** Presence endpoints share parent checks and return typed merged snapshots.
*******************************************************************************/
@@ -274,6 +320,50 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
assertError(400, "PUT", "/records/document/1/tags", "{\"add\":[1.5]}", "alice");
}
/*******************************************************************************
** The picker sends tag ids through the v1 table query as any JSON number or string.
*******************************************************************************/
@Test
void tagFilterByIdThroughTableQuery() throws Exception
{
RuntimeMetaDataOverlay.refresh(instance);
int a = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Alpha\"}").get("id").asInt();
int b = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Beta\"}").get("id").asInt();
ok("PUT", "/records/document/1/tags", "{\"add\":[" + a + "," + b + "]}");
ok("PUT", "/records/document/2/tags", "{\"add\":[" + a + "]}");
for(String ida : List.of(a + "", "\"" + a + "\"", a + ".0"))
{
assertThat(tagQuery("IN", "[" + ida + "]")).containsExactlyInAnyOrder(1, 2);
assertThat(tagQuery("EQUALS", "[" + ida + "]")).containsExactlyInAnyOrder(1, 2);
assertThat(tagQuery("NOT_IN", "[" + ida + "]")).isEmpty();
}
for(String idb : List.of(b + "", "\"" + b + "\"", b + ".0"))
{
assertThat(tagQuery("CONTAINS_ALL", "[" + a + "," + idb + "]")).containsExactly(1);
assertThat(tagQuery("NOT_IN", "[" + idb + "]")).containsExactly(2);
}
assertThat(tagQuery("IN", "[\"alpha\"]")).containsExactlyInAnyOrder(1, 2);
}
/*******************************************************************************
** Run the v1 table query filtered on the virtual tags field; return matching ids.
*******************************************************************************/
private List<Integer> tagQuery(String operator, String values) throws Exception
{
String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/"));
String body = "{\"filter\":{\"criteria\":[{\"fieldName\":\"collaborationTags\",\"operator\":\"" + operator + "\",\"values\":" + values + "}],\"orderBys\":[{\"fieldName\":\"id\"}]}}";
HttpResponse<String> response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/query")).timeout(Duration.ofSeconds(20))
.header("Cookie", "sessionUUID=alice" + sessionSuffix).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
JsonNode result = json(response, 200);
List<Integer> ids = new java.util.ArrayList<>();
for(JsonNode record : result.get("records"))
{
ids.add(record.at("/values/id").asInt());
}
return (ids);
}
/*******************************************************************************
** Comments, revisions, mentions, follow settings and the merged timeline.
*******************************************************************************/
@@ -300,12 +390,17 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isTrue();
ok("POST", "/comments/" + id + "/reopen", null);
assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isFalse();
JsonNode people = ok("GET", "/people?q=alice", null);
assertThat(people.at("/items/0/userId").asText()).isEqualTo("alice");
assertThat(ok("GET", "/people/alice", null).get("displayName").asText()).isEqualTo("alice");
JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=alice", null).at("/items/0");
assertThat(candidate.at("/person/userId").asText()).isEqualTo("alice");
assertThat(candidate.get("canRead").asText()).isEqualTo("unknown");
assertThat(json(request("GET", "/people/carol", null, "carol"), 200).get("displayName").asText()).isEqualTo("Carol Diaz");
JsonNode people = ok("GET", "/people?q=carol", null);
assertThat(people.at("/items/0/userId").asText()).isEqualTo("carol");
assertThat(ok("GET", "/people?q=alice", null).get("items")).as("the caller is not offered to themself").isEmpty();
assertThat(ok("GET", "/people", null).findValuesAsText("userId")).doesNotContain("alice").contains("carol");
assertThat(ok("GET", "/people/alice", null).get("displayName").asText()).isEqualTo("Alice Morgan");
assertThat(ok("GET", "/mentions/candidates?table=document&pk=1&q=alice", null).get("items")).isEmpty();
assertThat(ok("GET", "/mentions/candidates?table=document&pk=1", null).findValuesAsText("userId")).doesNotContain("alice");
JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=carol", null).at("/items/0");
assertThat(candidate.at("/person/userId").asText()).isEqualTo("carol");
assertThat(candidate.get("canRead").asText()).isEqualTo("true");
assertThat(ok("GET", "/records/document/1/follow", null).at("/follow/reason").asText()).isEqualTo("COMMENTED");
JsonNode state = ok("PUT", "/records/document/1/follow", "{\"notifyOnChange\":false,\"notifyOnComment\":true}");
assertThat(state.at("/follow/notifyOnChange").asBoolean()).isFalse();
@@ -439,15 +534,33 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
public void customizeSession(QInstance qInstance, QSession session, Map<String, Object> context)
{
String caller = session.getUuid().split("-")[0];
session.withUser(new QUser().withIdReference(caller).withFullName(caller));
session.withUser(new QUser().withIdReference(caller).withFullName(switch(caller)
{
case "alice" -> "Alice Morgan";
case "carol" -> "Carol Diaz";
default -> caller;
}));
session.withPermissions("document.read").withSecurityKeyValue("tenant", caller.equals("outsider") ? "b" : "a");
if(caller.equals("alice"))
if(caller.equals("alice") || caller.equals("carol"))
{
session.withPermissions("document.write");
}
}
}
/*******************************************************************************
** Carol is the one other person who can open the shared documents.
*******************************************************************************/
public static class CarolSessions implements UserSessionProviderInterface
{
@Override
public Optional<QSession> sessionForUser(String userId)
{
return (Optional.ofNullable("carol".equals(userId) ? new QSession().withUser(new QUser().withIdReference("carol")).withPermissions("document.read", "document.write")
.withSecurityKeyValue("tenant", "a") : null));
}
}
/*******************************************************************************
** Public no-arg adapter loaded through host configuration, with in-memory bytes.
*******************************************************************************/
@@ -23,7 +23,6 @@ package com.kingsrook.qbits.collaboration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
@@ -40,7 +39,8 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
@@ -115,8 +115,17 @@ class CollaborationTest
assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12);
assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName())
.containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET);
assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull();
assertThat(instance.getApp("collaboration").getWidgets()).containsExactlyElementsOf(CollaborationHome.HOME_WIDGETS);
for(String widget : CollaborationHome.HOME_WIDGETS)
{
assertThat(instance.getWidget(widget)).as(widget).isNotNull();
assertThat(instance.getWidget(widget).getGridColumns()).isEqualTo(12);
assertThat(instance.getWidget(widget).getIsCard()).isFalse();
}
assertThat(instance.getWidget(CollaborationHome.KPI_WIDGET).getType()).isEqualTo("multiStatistics");
assertThat(instance.getWidget(CollaborationHome.TAGS_WIDGET).getType()).isEqualTo("table");
assertThat(instance.getWidget(CollaborationHome.FIELDS_WIDGET).getType()).isEqualTo("table");
assertThat(instance.getWidget(CollaborationHome.PRESENCE_WIDGET).getType()).isEqualTo("table");
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getUniqueKeys().getFirst().getFieldNames()).containsExactly("scopeType", "scopeName", "feature");
assertThat(instance.getTable(CollaborationTables.USER).getUniqueKeys().getFirst().getFieldNames()).containsExactly("userId");
@@ -168,6 +177,41 @@ class CollaborationTest
/*******************************************************************************
** Without a parent app, Collaboration is a top-level node of the frontend app tree.
*******************************************************************************/
@Test
void appTreeTopLevelWithoutParent() throws Exception
{
new QInstanceValidator().validate(instance);
var output = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction().execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput());
assertThat(output.getApps()).containsKey("collaboration");
assertThat(output.getAppTree()).extracting(node -> node.getName()).contains("collaboration");
}
/*******************************************************************************
** With a parent app, Collaboration is nested under it and not duplicated at the top.
*******************************************************************************/
@Test
void appTreeNestedUnderParent() throws Exception
{
instance = new QInstance();
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
instance.setEnforcePermissionsInCoreActions(true);
instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin").withIcon(new QIcon("settings")));
new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory").withParentAppName("admin")).produce(instance);
QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("admin")).withPermissions(CollaborationPermissions.ADMIN));
new QInstanceValidator().validate(instance);
var output = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction().execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput());
assertThat(output.getAppTree()).extracting(node -> node.getName()).containsExactly("admin");
assertThat(output.getAppTree().getFirst().getChildren()).extracting(node -> node.getName()).contains("collaboration");
}
/*******************************************************************************
** Headless hosts keep the tables without app or widgets.
*******************************************************************************/
@@ -190,29 +234,59 @@ class CollaborationTest
@Test
void emptyHome() throws Exception
{
String html = CollaborationHome.render(Instant.now());
assertThat(html).contains("No feature settings yet: features follow the code defaults", "People known: 0", "Active in the last 7 days: 0", "No people yet");
RawHTML widget = (RawHTML) new CollaborationHome.HomeRenderer().render(new RenderWidgetInput()).getWidgetData();
assertThat(widget.getHtml()).contains("People known: 0");
assertThat(HomeWidgetData.stats(Instant.now())).containsExactly(
java.util.Map.entry("Comments this week", 0), java.util.Map.entry("Open threads", 0), java.util.Map.entry("Open tasks", 0),
java.util.Map.entry("Overdue tasks", 0), java.util.Map.entry("Open access requests", 0), java.util.Map.entry("Attachments", 0),
java.util.Map.entry("Active custom fields", 0));
assertThat(CollaborationHome.tags().getRows()).isEmpty();
assertThat(CollaborationHome.tags().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_TAGS);
assertThat(CollaborationHome.fields().getRows()).isEmpty();
assertThat(CollaborationHome.fields().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_FIELDS);
assertThat(CollaborationHome.presence().getRows()).isEmpty();
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo("Nobody else is here right now.");
assertThat(((TableData) new CollaborationHome.PresenceRenderer().render(new RenderWidgetInput()).getWidgetData()).getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NOBODY_HERE);
assertThat(new CollaborationHome.KpiRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(MultiStatisticsData.class);
assertThat(new CollaborationHome.TagsRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(TableData.class);
assertThat(new CollaborationHome.FieldsRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(TableData.class);
assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Work")).isEqualTo("Nothing waiting");
}
/*******************************************************************************
** Counts distinguish disabled settings and the inclusive seven-day window.
** Display names resolve in one query; unknown, blank and null ids stay readable.
*******************************************************************************/
@Test
void realCounts() throws Exception
void displayNamesResolveInBatches() throws Exception
{
Instant now = Instant.parse("2026-10-13T12:00:00Z");
seed(CollaborationTables.FEATURE_SETTING,
setting("INSTANCE", "instance", "COMMENTS", true), setting("APP", "sales", "TAGS", true),
setting("TABLE", "orders", "TASKS", true), setting("TABLE", "orders", "COMMENTS", true),
setting("TABLE", "orders", "PRESENCE", false));
seed(CollaborationTables.USER, person("one", now), person("boundary", now.minus(7, ChronoUnit.DAYS)),
person("old", now.minus(7, ChronoUnit.DAYS).minusSeconds(1)), person("never", null), person("future", now.plusSeconds(1)));
String html = CollaborationHome.render(now);
assertThat(html).contains("INSTANCE: 1", "APP: 1", "TABLE: 2", "People known: 5", "Active in the last 7 days: 2").doesNotContain("No feature settings yet", "No people yet");
seed(CollaborationTables.USER, new QRecord().withValue("userId", "ann").withValue("displayName", "Ann Lee").withValue("active", true),
new QRecord().withValue("userId", "nameless").withValue("displayName", " ").withValue("active", true));
var names = new com.kingsrook.qbits.collaboration.people.PeopleService().displayNames(java.util.Arrays.asList("ann", "nameless", "ghost", null, "", "ann"));
assertThat(names).containsExactly(java.util.Map.entry("ann", "Ann Lee"), java.util.Map.entry("nameless", "nameless"), java.util.Map.entry("ghost", "ghost"));
assertThat(new com.kingsrook.qbits.collaboration.people.PeopleService().displayNames(List.of())).isEmpty();
assertThat(instance.getTable(CollaborationTables.TASK).getField("assigneeUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
assertThat(instance.getTable(CollaborationTables.COMMENT).getField("authorUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
assertThat(instance.getTable(CollaborationTables.ATTACHMENT).getField("createdByUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
assertThat(instance.getTable(CollaborationTables.USER).getField("userId").getPossibleValueSourceName()).isNull();
assertThat(instance.getPossibleValueSource(CollaborationTables.USER).getOverrideIdField()).isEqualTo("userId");
}
/*******************************************************************************
** Tags and custom fields list real rows with table labels, All tables for global ones.
*******************************************************************************/
@Test
void tablesListRealRows() throws Exception
{
seed(CollaborationTables.TAG,
new QRecord().withValue("name", "global").withValue("label", "Global").withValue("approved", true).withValue("createdByUserId", "admin").withValue("usageCount", 3),
new QRecord().withValue("name", "popular").withValue("label", "Popular").withValue("approved", true).withValue("createdByUserId", "admin").withValue("usageCount", 5));
var tags = CollaborationHome.tags();
assertThat(tags.getColumns()).extracting(TableData.Column::getHeader).containsExactly("Label", "Table", "Records");
assertThat(tags.getRows()).hasSize(2);
assertThat(tags.getRows().get(0)).containsEntry("label", "Popular").containsEntry("table", "All tables").containsEntry("records", 5);
assertThat(tags.getRows().get(1)).containsEntry("label", "Global").containsEntry("table", "All tables").containsEntry("records", 3);
}
@@ -225,7 +299,7 @@ class CollaborationTest
{
QContext.getQSession().setPermissions(new java.util.HashSet<>());
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.USER))).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> CollaborationHome.render(Instant.now())).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> CollaborationHome.kpis(Instant.now())).isInstanceOf(QPermissionDeniedException.class);
QContext.getQSession().withPermissions(CollaborationPermissions.VIEW);
new QueryAction().execute(new QueryInput(CollaborationTables.FEATURE_SETTING));
new QueryAction().execute(new QueryInput(CollaborationTables.USER));
@@ -252,14 +326,16 @@ class CollaborationTest
void homeUsesCurrentSession() throws Exception
{
Instant now = Instant.now();
seed(CollaborationTables.USER, person("visible", now), person("hidden", now));
seed(CollaborationTables.TAG, new QRecord().withValue("name", "visible").withValue("label", "Visible").withValue("approved", true).withValue("createdByUserId", "admin"),
new QRecord().withValue("name", "hidden").withValue("label", "Hidden").withValue("approved", true).withValue("createdByUserId", "admin"));
instance.addSecurityKeyType(new QSecurityKeyType().withName("person").withAllAccessKeyName("allPeople"));
instance.getTable(CollaborationTables.USER).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("person").withFieldName("userId"));
instance.getTable(CollaborationTables.TAG).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("person").withFieldName("name"));
QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("viewer")).withPermissions(CollaborationPermissions.VIEW).withSecurityKeyValue("person", "visible"));
assertThat(CollaborationHome.render(now)).contains("People known: 1", "Active in the last 7 days: 1");
assertThat(CollaborationHome.tags().getRows()).singleElement().satisfies(row -> assertThat(row).containsEntry("label", "Visible"));
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY);
instance.setEnforcePermissionsInCoreActions(false);
QContext.getQSession().setPermissions(new java.util.HashSet<>());
assertThatThrownBy(() -> new CollaborationHome.HomeRenderer().render(new RenderWidgetInput())).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> new CollaborationHome.KpiRenderer().render(new RenderWidgetInput())).isInstanceOf(QPermissionDeniedException.class);
}
@@ -347,11 +347,15 @@ public class ConversationServicesTest extends PersonalServiceFixture
new PeopleService(Clock.fixed(at, ZoneOffset.UTC)).touch();
QContext.getQSession().getUser().setFullName("Changed Alice");
new PeopleService(Clock.fixed(at.plusSeconds(599), ZoneOffset.UTC)).touch();
assertThat(new PeopleService().search("Alice", 5)).singleElement().satisfies(p -> assertThat(p.displayName()).isEqualTo("Alice Example"));
new PeopleService(Clock.fixed(at.plusSeconds(600), ZoneOffset.UTC)).touch();
assertThat(new PeopleService().search("changed", 5)).hasSize(1);
assertThat(new PeopleService().search("Alice", 5)).as("the caller never finds themself").isEmpty();
login("bob");
new PeopleService().touch();
assertThat(new PeopleService().search("Alice", 5)).singleElement().satisfies(p -> assertThat(p.displayName()).isEqualTo("Alice Example"));
login("alice");
QContext.getQSession().getUser().setFullName("Changed Alice");
new PeopleService(Clock.fixed(at.plusSeconds(600), ZoneOffset.UTC)).touch();
login("bob");
assertThat(new PeopleService().search("changed", 5)).hasSize(1);
follows.follow("document", 1, null, null);
login("eve");
new PeopleService().touch();
@@ -507,8 +511,7 @@ public class ConversationServicesTest extends PersonalServiceFixture
assertThat(sql).contains("ON collaboration_comment (table_name, record_id, create_date)", "ON collaboration_follow_digest (due_at)");
comments.post("document", 1, "home", null, null);
admin();
String html = com.kingsrook.qbits.collaboration.app.CollaborationHome.render(Instant.now().plusSeconds(1));
assertThat(html).contains("Comments this week</h3><p>1", "Open threads</h3><p>1", "Followers</h3><p>1");
assertThat(HomeWidgetData.stats(Instant.now().plusSeconds(1))).containsEntry("Comments this week", 1).containsEntry("Open threads", 1);
assertThatThrownBy(() -> config.withFollowActionTables(List.of("missing")).validate(instance)).hasMessageContaining("host table");
}
@@ -48,6 +48,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -81,6 +83,26 @@ public class CustomFieldsTest extends PersonalServiceFixture
/*******************************************************************************
** The home lists definitions with table label, status and index state, and counts active ones.
*******************************************************************************/
@Test
void homeListsDefinitionsAndCountsActiveOnes() throws Exception
{
admin();
QRecord definition = draft("region", "STRING");
var drafts = CollaborationHome.fields();
assertThat(drafts.getColumns()).extracting(TableData.Column::getHeader).containsExactly("Name", "Table", "Status", "Index state");
assertThat(drafts.getRows()).singleElement().satisfies(row -> assertThat(row).containsEntry("name", "region").containsEntry("table", "Documents")
.containsEntry("status", "Draft").containsEntry("indexState", "None"));
assertThat(HomeWidgetData.stats(java.time.Instant.now())).containsEntry("Active custom fields", 0);
FieldDefinitions.apply(definition.getValue("id"), "Publish");
assertThat(CollaborationHome.fields().getRows().getFirst()).containsEntry("status", "Active");
assertThat(HomeWidgetData.stats(java.time.Instant.now())).containsEntry("Active custom fields", 1);
}
/*******************************************************************************
** Create a real draft through the permission and pre-insert customizers.
*******************************************************************************/
@@ -0,0 +1,66 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
/*******************************************************************************
** Test view of the home KPI widget: statistic label to value, and group subheaders.
*******************************************************************************/
public final class HomeWidgetData
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private HomeWidgetData()
{
}
/*******************************************************************************
** Every statistic on the KPI widget, by label.
*******************************************************************************/
public static Map<String, Integer> stats(Instant now) throws QException
{
Map<String, Integer> result = new LinkedHashMap<>();
CollaborationHome.kpis(now).getStatisticsGroupData().forEach(group -> group.getStatisticList().forEach(stat -> result.put(stat.getLabel(), stat.getValue())));
return (result);
}
/*******************************************************************************
** The subheader of the named KPI group.
*******************************************************************************/
public static String subheader(MultiStatisticsData data, String header)
{
return (data.getStatisticsGroupData().stream().filter(group -> header.equals(group.getHeader())).findFirst().orElseThrow().getSubheader());
}
}
@@ -134,13 +134,19 @@ class PresenceTest extends PersonalServiceFixture
login("alice");
assertThat(service.snapshot("document", "1")).isEmpty();
service.heartbeat("table", "document", null, "VIEWING", null);
admin();
assertThat(CollaborationHome.presence().getRows()).extracting(row -> row.get("table")).containsExactly("Documents");
assertThat(CollaborationHome.presence().getRows().getFirst().get("viewers")).isEqualTo(1);
login("alice");
assertThat(CollaborationHome.presence().getRows()).isEmpty();
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY);
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.PRESENCE, false);
RuntimeMetaDataOverlay.refresh(instance);
assertThatThrownBy(() -> service.snapshot("document", null)).isInstanceOf(QNotFoundException.class);
assertThatThrownBy(() -> service.heartbeat("table", "document", null, "VIEWING", null)).isInstanceOf(QNotFoundException.class);
admin();
assertThat(CollaborationRecords.query(com.kingsrook.qbits.collaboration.model.CollaborationTables.USER, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty();
assertThat(CollaborationHome.render(Instant.now())).contains("Who's here", "Custom fields", "Index NONE");
assertThat(CollaborationHome.fields().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_FIELDS);
}
/*******************************************************************************
@@ -31,7 +31,6 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
import com.kingsrook.qbits.collaboration.comments.CommentsService;
import com.kingsrook.qbits.collaboration.follow.FollowService;
@@ -510,8 +509,7 @@ public class SharingWorkTest extends PersonalServiceFixture
sharing.share("document", 1, List.of("bob"), null);
access.request("document", 999, null, null);
admin();
String html = CollaborationHome.render(Instant.now().plusSeconds(5));
assertThat(html).contains("Open access requests</h3><p>1", "Open tasks</h3><p>1", "Overdue tasks</h3><p>1", "Shares this week</h3><p>1");
assertThat(HomeWidgetData.stats(Instant.now().plusSeconds(5))).containsEntry("Open access requests", 1).containsEntry("Open tasks", 1).containsEntry("Overdue tasks", 1);
assertThat(SendTaskRemindersProcess.metadata(config).getIsHidden()).isTrue();
assertThat(SendTaskRemindersProcess.metadata(config).getSchedule()).isNull();
assertThat(SendTaskRemindersProcess.metadata(config.withSchedulerName("clock")).getSchedule().getRepeatSeconds()).isEqualTo(60);
@@ -22,7 +22,6 @@
package com.kingsrook.qbits.collaboration;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -311,9 +310,11 @@ public class TagsServiceTest extends PersonalServiceFixture
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b"));
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_TAG)).getRecords()).isEmpty();
login("manager", "collaboration.admin", TagsService.MANAGE);
String html = CollaborationHome.render(Instant.now());
assertThat(html).contains("Vocabulary size: 2", "Pending suggestions: 1", "&lt;Urgent&gt;: 1");
assertThat(html).doesNotContain("<Urgent>");
var home = CollaborationHome.tags();
assertThat(home.getRows()).hasSize(2);
assertThat(home.getRows().getFirst()).containsEntry("label", "<Urgent>").containsEntry("table", "Documents").containsEntry("records", 1);
assertThat(home.getRows().getLast()).containsEntry("records", 0);
assertThat(home.getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_TAGS);
}
/*******************************************************************************
@@ -445,6 +446,36 @@ public class TagsServiceTest extends PersonalServiceFixture
{
return ((List<ProcessSummaryLine>) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY));
}
/*******************************************************************************
** JSON clients send ids as Integer, Long, BigDecimal or String; all select by id.
*******************************************************************************/
@Test
void translatorAcceptsEveryIdRepresentation() throws Exception
{
TagEntry a = tags.create("document", "A", null);
TagEntry b = tags.create("document", "B", null);
TagEntry numeric = tags.create("document", "77", null);
tags.apply("document", 1, List.of(a.id(), b.id()), List.of());
tags.apply("document", 2, List.of(a.id()), List.of());
for(java.io.Serializable idA : List.<java.io.Serializable>of(a.id(), Long.valueOf(a.id()), new java.math.BigDecimal(a.id()), String.valueOf(a.id())))
{
assertThat(recordIds("document", "IN", List.of(idA))).containsExactly("1", "2");
assertThat(recordIds("document", "EQUALS", List.of(idA))).containsExactly("1", "2");
assertThat(recordIds("document", "NOT_IN", List.of(idA))).isEmpty();
}
for(java.io.Serializable idB : List.<java.io.Serializable>of(b.id(), Long.valueOf(b.id()), new java.math.BigDecimal(b.id()), String.valueOf(b.id())))
{
assertThat(recordIds("document", "IN", List.of(idB))).containsExactly("1");
assertThat(recordIds("document", "NOT_IN", List.of(idB))).containsExactly("2");
assertThat(recordIds("document", "CONTAINS_ALL", List.of(String.valueOf(a.id()), idB))).containsExactly("1");
}
assertThat(recordIds("document", "CONTAINS_ALL", List.of(new java.math.BigDecimal(a.id()), Long.valueOf(b.id())))).containsExactly("1");
assertThat(recordIds("document", "IN", List.of("9999"))).isEmpty();
tags.apply("document", 2, List.of(numeric.id()), List.of());
assertThat(recordIds("document", "IN", List.of(String.valueOf(numeric.id())))).containsExactly("2");
assertThat(recordIds("document", "IN", List.of("77"))).containsExactly("2");
}
/*******************************************************************************
** Execute expanded criteria through the real parent query action.
*******************************************************************************/
@@ -26,6 +26,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.timeline.TimelineDetail;
import com.kingsrook.qbits.collaboration.timeline.TimelineItem;
import com.kingsrook.qbits.collaboration.timeline.TimelinePage;
import com.kingsrook.qbits.collaboration.timeline.TimelineService;
@@ -116,6 +117,48 @@ public class TimelineServiceTest extends PersonalServiceFixture
assertThat(timeline.timeline("document", 2, null, 50, null).items()).isEmpty();
}
/*******************************************************************************
** Core wording becomes who-did-what; detail rows and unknown messages are untouched.
*******************************************************************************/
@Test
void auditSummariesSayWhoDidWhat() throws Exception
{
seedAudit(4, "Record was Edited", 100);
seedAudit(5, "Record was Inserted", 101);
seedAudit(6, "Record was Edited", 102);
seedAudit(7, "Record was Deleted", 103);
seed("auditDetail", new QRecord().withValue("id", 10L).withValue("auditId", 4L).withValue("fieldName", "title").withValue("oldValue", "Old").withValue("newValue", "New"));
seed("auditDetail", new QRecord().withValue("id", 11L).withValue("auditId", 4L).withValue("fieldName", "tenant").withValue("oldValue", "a").withValue("newValue", "b"));
seed("auditDetail", new QRecord().withValue("id", 12L).withValue("auditId", 4L).withValue("fieldName", "title").withValue("oldValue", "New").withValue("newValue", "Newer"));
seed("auditDetail", new QRecord().withValue("id", 13L).withValue("auditId", 5L).withValue("fieldName", "title").withValue("newValue", "Created title"));
var items = timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items();
java.util.function.Function<Long, TimelineItem> byId = id -> items.stream().filter(item -> item.id().equals(id)).findFirst().orElseThrow();
assertThat(byId.apply(4L).summary()).isEqualTo("Edited by Audit Actor: Title, Tenant");
assertThat(byId.apply(4L).details()).hasSize(3);
assertThat(byId.apply(5L).summary()).isEqualTo("Created by Audit Actor");
assertThat(byId.apply(5L).details()).hasSize(1);
assertThat(byId.apply(6L).summary()).isEqualTo("Edited by Audit Actor");
assertThat(byId.apply(7L).summary()).isEqualTo("Deleted by Audit Actor");
assertThat(byId.apply(1L).summary()).as("unrecognized messages are kept").isEqualTo("Record changed");
assertThat(items).extracting(TimelineItem::summary).noneMatch(summary -> summary.startsWith("Record was"));
assertThat(TimelineService.friendlySummary("Record was Inserted", null, List.of(), null)).isEqualTo("Created");
assertThat(TimelineService.friendlySummary("Record was Inserted via import", " ", List.of(), null)).isEqualTo("Created");
assertThat(TimelineService.friendlySummary(null, "A", List.of(), null)).isNull();
List<TimelineDetail> many = java.util.stream.IntStream.rangeClosed(1, 6).mapToObj(n -> new TimelineDetail("f" + n, "a", "b", null)).toList();
assertThat(TimelineService.friendlySummary("Record was Edited", "A", many, null)).isEqualTo("Edited by A: f1, f2, f3, f4 and 2 more");
assertThat(TimelineService.friendlySummary("Record was Edited", "A", List.of(new TimelineDetail(null, null, null, "note"), new TimelineDetail(" ", null, null, null)), null)).isEqualTo("Edited by A");
}
/*******************************************************************************
** One audit header for the first document, as a different time.
*******************************************************************************/
private void seedAudit(long id, String message, long secondsAfter) throws Exception
{
seed("audit", new QRecord().withValue("id", id).withValue("auditTableId", 1).withValue("auditUserId", 1)
.withValue("recordId", "1").withValue("timestamp", at.plusSeconds(secondsAfter)).withValue("message", message));
}
/*******************************************************************************
** Audit read permission and hidden fields apply.
*******************************************************************************/
@@ -34,6 +34,7 @@ import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationSchema;
import com.kingsrook.qbits.collaboration.HomeWidgetData;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
@@ -430,10 +431,17 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
void homeAndSchema() throws Exception
{
admin();
assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 0", "No attachments yet.", "Total size (bytes): 0");
assertThat(HomeWidgetData.stats(Instant.now())).containsEntry("Attachments", 0);
assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Attachments")).isEqualTo("No attachments yet");
upload("a.txt", "12345");
upload("b.txt", "123");
assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 2", "Total size (bytes): 8", "Approximate totals").doesNotContain("No attachments yet.");
assertThat(HomeWidgetData.stats(Instant.now())).containsEntry("Attachments", 2);
assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Attachments")).isEqualTo("8 B in total");
assertThat(CollaborationHome.humanSize(0)).isEqualTo("0 B");
assertThat(CollaborationHome.humanSize(1023)).isEqualTo("1023 B");
assertThat(CollaborationHome.humanSize(4813)).isEqualTo("4.7 KB");
assertThat(CollaborationHome.humanSize(5L * 1024 * 1024)).isEqualTo("5.0 MB");
assertThat(CollaborationHome.humanSize(3L * 1024 * 1024 * 1024)).isEqualTo("3.0 GB");
String ddl = CollaborationSchema.postgresScript(config);
assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS collaboration_attachment", "size_bytes BIGINT", "parent_deleted BOOLEAN DEFAULT FALSE NOT NULL",
"ON collaboration_attachment (table_name, record_id)").doesNotContain("preview_url", "download_url");
@@ -36,6 +36,7 @@ import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -123,6 +124,8 @@ public class CollaborationDevServer extends AbstractQQQApplication
{
private final QInstance instance;
static final List<String> USERS = List.of("alice", "bob", "admin");
static final String USER_COOKIE = "collab_dev_user";
private static final ThreadLocal<String> REQUEST_USER = new ThreadLocal<>();
@@ -155,7 +158,7 @@ public class CollaborationDevServer extends AbstractQQQApplication
String user = System.getProperty("collab.dev.user", "alice");
session(user);
int port = Integer.parseInt(System.getProperty("collab.dev.port", "8098"));
QInstance instance = buildInstance();
QInstance instance = buildInstance(port);
seed(instance);
QApplicationJavalinServer server = server(instance, port, true);
server.start();
@@ -184,16 +187,63 @@ public class CollaborationDevServer extends AbstractQQQApplication
.withJavalinConfigCustomizer(config ->
{
config.jetty.host = "127.0.0.1";
userSelection(config);
MemoryStorage.routes(config);
}));
}
/*******************************************************************************
** Pick the demo user per browser, not per JVM. A request carrying ?user=bob (for
** example the page load of /?createMockSession=true&user=bob) is remembered in a
** cookie, and every request exposes its choice to the session customizer, which
** QQQ calls without the request. Without either, the collab.dev.user default holds.
*******************************************************************************/
static void userSelection(JavalinConfig config)
{
config.routes.before(context ->
{
String requested = context.queryParam("user");
if(requested != null && USERS.contains(requested))
{
context.cookie(USER_COOKIE, requested);
REQUEST_USER.set(requested);
return;
}
String remembered = context.cookie(USER_COOKIE);
REQUEST_USER.set(remembered != null && USERS.contains(remembered) ? remembered : null);
});
config.routes.after(context -> REQUEST_USER.remove());
}
/*******************************************************************************
** The user for the request being handled on this thread, else the JVM default.
*******************************************************************************/
static String selectedUser()
{
String requested = REQUEST_USER.get();
return (requested != null ? requested : System.getProperty("collab.dev.user", "alice"));
}
/*******************************************************************************
** Normal host metadata, plus the same collaboration installation as a real app.
*******************************************************************************/
static QInstance buildInstance() throws Exception
{
return (buildInstance(Integer.parseInt(System.getProperty("collab.dev.port", "8098"))));
}
/*******************************************************************************
** The port is part of the configuration: shared links are absolute URLs to this server.
*******************************************************************************/
static QInstance buildInstance(int port) throws Exception
{
QInstance instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData()
.withName("mock").withType(QAuthenticationType.MOCK).withCustomizer(new QCodeReference(DemoSessions.class)));
@@ -232,7 +282,7 @@ public class CollaborationDevServer extends AbstractQQQApplication
new AuditsMetaDataProvider().withRecordIdType(QFieldType.STRING).withDefineRecordScreens(true)
.defineAll(instance, "memory", table -> table.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS)));
new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory")
.withAdminApp(true).withParentAppName("admin").withUiApi(true)
.withAdminApp(true).withParentAppName("admin").withUiApi(true).withFrontendBaseUrl("http://localhost:" + port)
.withTableFeatures("person", RecordFeature.values()).withTableFeatures("sales_order", RecordFeature.values())
.withAttachmentStorage(new QCodeReference(MemoryStorage.class))
.withUserSessionProvider(new QCodeReference(DemoSessions.class))
@@ -282,7 +332,8 @@ public class CollaborationDevServer extends AbstractQQQApplication
default -> "Demo Administrator";
};
QSession session = new QSession().withUser(new QUser().withIdReference(user).withFullName(name))
.withPermissions("person.read", "person.comment");
.withPermissions("person.read", "person.comment")
.withValueForFrontend("user", new HashMap<>(Map.of("name", name, "email", user + "@example.com")));
if(!user.equals("bob"))
{
session.withPermissions("audit.read", "auditDetail.read", "auditTable.read", "auditUser.read");
@@ -296,7 +347,7 @@ public class CollaborationDevServer extends AbstractQQQApplication
}
if(user.equals("admin"))
{
session.withPermissions("admin.access", "collaboration.admin", "collaboration.view", "collaboration.tags.manage", "collaboration.fields.manage");
session.withPermissions("admin.hasAccess", "collaboration.admin", "collaboration.view", "collaboration.tags.manage", "collaboration.fields.manage");
}
return (session);
}
@@ -314,9 +365,10 @@ public class CollaborationDevServer extends AbstractQQQApplication
@Override
public void customizeSession(QInstance instance, QSession target, Map<String, Object> context)
{
QSession selected = session(System.getProperty("collab.dev.user", "alice"));
QSession selected = session(selectedUser());
target.setUser(selected.getUser());
target.setPermissions(selected.getPermissions());
target.setValuesForFrontend(selected.getValuesForFrontend());
}
@@ -81,7 +81,12 @@ class CollaborationDevServerTest
try(HttpClient client = HttpClient.newHttpClient())
{
System.setProperty("collab.dev.user", "alice");
instance = CollaborationDevServer.buildInstance();
int port;
try(ServerSocket socket = new ServerSocket(0))
{
port = socket.getLocalPort();
}
instance = CollaborationDevServer.buildInstance(port);
CollaborationDevServer.seed(instance);
new QInstanceValidator().validate(instance);
assertThat(rows("person")).hasSize(25);
@@ -115,6 +120,8 @@ class CollaborationDevServerTest
assertThat(detail.oldValue()).isEqualTo("Purchasing Director");
assertThat(detail.newValue()).isEqualTo("VP of Purchasing");
}));
assertThat(new TimelineService().timeline("person", 1, null, 100, null).items()).extracting(item -> item.summary())
.anyMatch(summary -> summary.startsWith("Edited by ") && summary.contains("Title")).noneMatch(summary -> summary.startsWith("Record was"));
assertThat(new SharingService().preview("person", 1, List.of("bob")).getFirst().canRead()).isTrue();
assertThat(new SharingService().preview("sales_order", 1, List.of("bob")).getFirst().canRead()).isFalse();
QContext.setQSession(CollaborationDevServer.session("admin"));
@@ -139,14 +146,11 @@ class CollaborationDevServerTest
QSession mock = new MockAuthenticationModule().createSession(instance, Map.of("sessionUUID", UUID.randomUUID().toString()));
assertThat(mock.getUser().getIdReference()).isEqualTo(user);
assertThat(mock.getPermissions()).isEqualTo(CollaborationDevServer.session(user).getPermissions());
assertThat(mock.getUser().getFullName()).isEqualTo(CollaborationDevServer.session(user).getUser().getFullName()).isNotEqualTo("Anonymous").isNotEqualTo("John Smith");
assertThat(mock.getValuesForFrontend()).containsEntry("user", CollaborationDevServer.session(user).getValuesForFrontend().get("user"));
}
assertThatThrownBy(() -> CollaborationDevServer.session("unknown")).isInstanceOf(IllegalArgumentException.class);
System.setProperty("collab.dev.user", "alice");
int port;
try(ServerSocket socket = new ServerSocket(0))
{
port = socket.getLocalPort();
}
System.setProperty("collab.dev.port", Integer.toString(port));
server = CollaborationDevServer.server(instance, port, false);
server.start();
@@ -156,6 +160,7 @@ class CollaborationDevServerTest
assertThat(response.statusCode()).isEqualTo(200);
assertThat(new ObjectMapper().readTree(response.body()).get("threads")).hasSize(3);
QContext.init(instance, CollaborationDevServer.session("alice"));
assertThat(new SharingService().link("person", 1).url()).isEqualTo("http://localhost:" + port + "/app/person/1");
AttachmentsService attachments = new AttachmentsService();
var png = attachments.list("person", 1).getFirst();
var image = get(client, attachments.downloadUrl(png.id(), true).toString());
@@ -193,6 +198,114 @@ class CollaborationDevServerTest
/*******************************************************************************
** Two browsers see two users: ?user= is remembered in a cookie, the session carries
** the person's real name, and without a choice the JVM default still applies.
*******************************************************************************/
@Test
void eachBrowserSessionChoosesItsOwnUser() throws Exception
{
String oldUser = System.getProperty("collab.dev.user");
MemoryRecordStore.getInstance().reset();
CollaborationDevServer.MemoryStorage.clear();
QInstance instance = null;
QApplicationJavalinServer server = null;
try(HttpClient client = HttpClient.newHttpClient())
{
System.setProperty("collab.dev.user", "admin");
int port;
try(ServerSocket socket = new ServerSocket(0))
{
port = socket.getLocalPort();
}
instance = CollaborationDevServer.buildInstance(port);
CollaborationDevServer.seed(instance);
server = CollaborationDevServer.server(instance, port, false);
server.start();
String base = "http://localhost:" + port;
var chosen = get(client, base + "/qqq/v1/version?createMockSession=true&user=bob");
assertThat(chosen.headers().allValues("Set-Cookie")).anySatisfy(cookie -> assertThat(cookie).startsWith("collab_dev_user=bob"));
assertThat(get(client, base + "/qqq/v1/version?user=nobody").headers().allValues("Set-Cookie")).noneMatch(cookie -> cookie.startsWith("collab_dev_user"));
var manage = client.send(HttpRequest.newBuilder(URI.create(base + "/qqq/v1/manageSession")).timeout(Duration.ofSeconds(20))
.header("Cookie", "collab_dev_user=bob").header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"accessToken\":\"anonymous\"}")).build(), HttpResponse.BodyHandlers.ofString());
assertThat(new ObjectMapper().readTree(manage.body()).at("/values/user/name").asText()).as(manage.body()).isEqualTo("Bob Chen");
assertThat(commenter(client, base, "bob")).containsExactly("bob", "Bob Chen");
assertThat(commenter(client, base, "alice")).containsExactly("alice", "Alice Morgan");
assertThat(commenter(client, base, "admin")).containsExactly("admin", "Demo Administrator");
assertThat(commenter(client, base, null)).as("no choice falls back to the collab.dev.user default").containsExactly("admin", "Demo Administrator");
assertThat(commenter(client, base, "nobody")).as("an unknown cookie falls back to the default").containsExactly("admin", "Demo Administrator");
assertThat(CollaborationDevServer.selectedUser()).isEqualTo("admin");
}
finally
{
if(server != null)
{
server.stop();
}
if(instance != null)
{
PresenceRegistry.close(instance);
RealtimeBus.close(instance);
}
QContext.clear();
QJavalinImplementation.setSessionCookieHttpOnly(false);
MemoryRecordStore.getInstance().reset();
CollaborationDevServer.MemoryStorage.clear();
restore("collab.dev.user", oldUser);
}
}
/*******************************************************************************
** Post a comment as the browser holding the given user cookie; return who the server says wrote it.
*******************************************************************************/
private static List<String> commenter(HttpClient client, String base, String cookieUser) throws Exception
{
String cookie = "sessionUUID=" + UUID.randomUUID() + (cookieUser == null ? "" : "; collab_dev_user=" + cookieUser);
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/qqq/v1/qbit/collaboration/records/person/1/comments")).timeout(Duration.ofSeconds(20))
.header("Cookie", cookie).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"body\":\"Who am I?\"}")).build(), HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
var json = new ObjectMapper().readTree(response.body());
return (List.of(json.get("authorUserId").asText(), json.get("authorDisplayName").asText()));
}
/*******************************************************************************
** The demo places Collaboration inside Admin, and the admin demo user can navigate to it.
*******************************************************************************/
@Test
void collaborationIsInTheAppTreeUnderAdmin() throws Exception
{
MemoryRecordStore.getInstance().reset();
CollaborationDevServer.MemoryStorage.clear();
QInstance instance = CollaborationDevServer.buildInstance();
try
{
QContext.init(instance, CollaborationDevServer.session("admin"));
var tree = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction()
.execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput()).getAppTree();
var admin = tree.stream().filter(node -> node.getName().equals("admin")).findFirst();
assertThat(admin).as("admin sees the Admin app").isPresent();
assertThat(admin.get().getChildren()).extracting(node -> node.getName()).contains("collaboration");
assertThat(tree).extracting(node -> node.getName()).doesNotContain("collaboration");
QContext.init(instance, CollaborationDevServer.session("alice"));
var aliceTree = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction()
.execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput()).getAppTree();
assertThat(aliceTree).extracting(node -> node.getName()).contains("sales").doesNotContain("admin", "collaboration");
}
finally
{
PresenceRegistry.close(instance);
RealtimeBus.close(instance);
QContext.clear();
MemoryRecordStore.getInstance().reset();
}
}
/*******************************************************************************
** Read through normal permissions, including the seeded admin-only tables.
*******************************************************************************/
@@ -0,0 +1,242 @@
/*
* 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.email;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
import com.kingsrook.qbits.collaboration.notify.NoOpNotifier;
import com.kingsrook.qbits.collaboration.notify.Notifications;
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
import com.kingsrook.qbits.collaboration.sharing.SharingService;
import com.kingsrook.qbits.collaboration.tasks.TasksService;
import com.kingsrook.qbits.email.EmailQBitConfig;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qbits.email.metadata.EmailTables;
import com.kingsrook.qbits.email.permissions.EmailPermissions;
import com.kingsrook.qbits.email.support.SystemWrites;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Collaboration notifications through a real qbit-email outbox (memory backend).
*******************************************************************************/
class EmailCollaborationNotifierTest extends PersonalServiceFixture
{
private static final Map<String, QSession> sessions = new HashMap<>();
/*******************************************************************************
** Sessions for the recipients, so access is re-checked as them.
*******************************************************************************/
public static class TestSessions implements UserSessionProviderInterface
{
/*******************************************************************************
** The recipient own session.
*******************************************************************************/
@Override
public Optional<QSession> sessionForUser(String user)
{
return (Optional.ofNullable(sessions.get(user)));
}
}
/*******************************************************************************
** Recipients bob and eve can read the document and have addresses.
*******************************************************************************/
@BeforeEach
void configure() throws Exception
{
sessions.clear();
for(String user : List.of("alice", "bob", "eve"))
{
sessions.put(user, new QSession().withUser(new QUser().withIdReference(user)).withPermissions("document.read", "document.task").withSecurityKeyValue("tenant", "a"));
}
config.withUserSessionProvider(new QCodeReference(TestSessions.class));
CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "bob").withValue("displayName", "Bob").withValue("email", "bob@example.org"));
CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "eve").withValue("displayName", "Eve").withValue("email", "eve@example.org"));
}
/*******************************************************************************
** Produce the email qbit in the test instance.
*******************************************************************************/
private void withEmail() throws Exception
{
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withPublicBaseUrl("https://app.example.org")
.withSecretResolver(name -> "k")).produce(instance);
QContext.getQSession().withPermissions(EmailPermissions.ALL);
SystemWrites.insertOne(EmailTables.PROVIDER, new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP").withValue("host", "127.0.0.1")
.withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true));
}
/*******************************************************************************
** All outbox messages.
*******************************************************************************/
private List<QRecord> messages() throws Exception
{
return (SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter()));
}
/*******************************************************************************
** A notification of a category for a recipient.
*******************************************************************************/
private CollaborationNotification notification(String recipient, CollaborationNotification.Category category, String eventId)
{
return (new CollaborationNotification(recipient, "You were mentioned in a comment", "secret comment body", category, "document", "1",
"https://app.example.org/app/document/1", eventId, "alice"));
}
/*******************************************************************************
** With the email qbit present and no notifier configured, a mention becomes one
** NOTIFICATION message with the key, link and record, and no comment body; a
** repeat is a duplicate.
*******************************************************************************/
@Test
void mentionQueuesOneMessageAndRepeatIsDuplicate() throws Exception
{
withEmail();
assertThat(config.getNotifier()).isNull();
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
assertThat(messages()).singleElement().satisfies(message ->
{
assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION");
assertThat(message.getValueString("toAddresses")).contains("bob@example.org").doesNotContain("eve@example.org");
assertThat(message.getValueString("idempotencyKey")).isEqualTo("collaboration:mention:7:bob");
assertThat(message.getValueString("linkedTableName")).isEqualTo("document");
assertThat(message.getValueString("linkedRecordId")).isEqualTo("1");
assertThat(message.getValueString("senderQbit")).isEqualTo("qbit-collaboration");
assertThat(message.getValueString("subject")).contains("alice mentioned you on a record in Documents");
assertThat(message.getValueString("subject") + message.getValueString("textBody") + message.getValueString("htmlBody")).doesNotContain("secret comment body");
assertThat(message.getValueString("htmlBody")).contains("https://app.example.org/app/document/1");
});
assertThat(SystemWrites.query(EmailTables.TEMPLATE, new QQueryFilter())).extracting(t -> t.getValueString("name")).contains("collaboration_mention");
}
/*******************************************************************************
** Each recipient gets their own message; the share and task kinds use their
** own templates and key kinds; follow digests send no email.
*******************************************************************************/
@Test
void oneMessagePerRecipientAndKinds() throws Exception
{
withEmail();
Notifications.send(notification("bob", CollaborationNotification.Category.SHARE, "3"));
Notifications.send(notification("eve", CollaborationNotification.Category.SHARE, "3"));
Notifications.send(notification("bob", CollaborationNotification.Category.TASK, "9:remindAt"));
Notifications.send(notification("bob", CollaborationNotification.Category.FOLLOW, "1"));
assertThat(messages()).extracting(m -> m.getValueString("idempotencyKey"))
.containsExactlyInAnyOrder("collaboration:share:3:bob", "collaboration:share:3:eve", "collaboration:task:9:remindAt:bob");
assertThat(messages()).allSatisfy(m -> assertThat(m.getValueString("toAddresses")).doesNotContain(","));
assertThat(SystemWrites.query(EmailTables.TEMPLATE, new QQueryFilter())).extracting(t -> t.getValueString("name"))
.contains("collaboration_share", "collaboration_task_reminder");
}
/*******************************************************************************
** The configured resolver replaces the directory; no address means no email.
*******************************************************************************/
@Test
void resolverAndMissingAddress() throws Exception
{
withEmail();
config.withRecipientEmailResolver(user -> "eve".equals(user) ? "other@example.org" : null);
Notifications.send(notification("eve", CollaborationNotification.Category.MENTION, "1"));
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "2"));
assertThat(messages()).singleElement().satisfies(m -> assertThat(m.getValueString("toAddresses")).contains("other@example.org"));
}
/*******************************************************************************
** Real shares and task assignments carry the event id and actor into the email.
*******************************************************************************/
@Test
void sharesAndTasksThroughTheServices() throws Exception
{
withEmail();
new SharingService().share("document", 1, List.of("bob"), "private message");
new TasksService().create("document", 1, "Private task title", "private notes", "bob", null, null);
assertThat(messages()).hasSize(2).allSatisfy(m ->
{
assertThat(m.getValueString("subject") + m.getValueString("textBody") + m.getValueString("htmlBody")).doesNotContain("private", "Private");
assertThat(m.getValueString("idempotencyKey")).matches("collaboration:(share|task):\\d+:bob");
});
assertThat(messages().getFirst().getValueString("subject")).startsWith("alice shared");
}
/*******************************************************************************
** Without the email qbit in the instance nothing is sent and no receipt runs.
*******************************************************************************/
@Test
void withoutEmailQbitNothingHappens() throws Exception
{
boolean[] receipt = {false};
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"), () -> receipt[0] = true);
assertThat(receipt[0]).isFalse();
assertThat(EmailCollaborationNotifier.isAvailable(instance)).isFalse();
}
/*******************************************************************************
** A configured notifier always wins over the email default.
*******************************************************************************/
@Test
void configuredNotifierWins() throws Exception
{
withEmail();
config.withNotifier(new QCodeReference(NoOpNotifier.class));
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
assertThat(messages()).isEmpty();
}
}
@@ -33,11 +33,8 @@ import com.kingsrook.qbits.collaboration.CollaborationSchema;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
@@ -257,27 +254,6 @@ class RecentsServiceTest extends PersonalServiceFixture
/*******************************************************************************
** Counts use the current user's owner lock and admins see real distinct users.
*******************************************************************************/
@Test
void homeSummaryCounts() throws Exception
{
at(START);
login("alice", CollaborationPermissions.VIEW);
FavoritesService.star("TABLE", "document", null);
FavoritesService.star("RECORD", "document", "1");
RecentsService.recordView("TABLE", "document", null);
login("bob", CollaborationPermissions.VIEW);
FavoritesService.star("TABLE", "document", null);
RecentsService.recordView("TABLE", "document", null);
assertThat(CollaborationHome.render(START)).contains("<h3>Favorites</h3><p>Rows: 1</p><p>Distinct users: 1", "<h3>Recently viewed</h3><p>Rows: 1</p><p>Distinct users: 1");
admin();
assertThat(CollaborationHome.render(START)).contains("<h3>Favorites</h3><p>Rows: 3</p><p>Distinct users: 2", "<h3>Recently viewed</h3><p>Rows: 2</p><p>Distinct users: 2");
}
/*******************************************************************************
** Freeze the service clock, including exact boundary cases.
*******************************************************************************/