Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 d5adcec567 refactor(email): use qbit-email's public api package; no G1 allow-list
Moves the optional email integration from qbit-email internals to
com.kingsrook.qbits.email.api (qbit-email 0.1.0-RC.2) with identical
behaviour, and moves to qbit-build-parent 2.1.1, whose default G1
allow-lists include qbit-email's API (QRun-IO/qbit-bom#12), so the
per-repo allow-lists are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:20:32 -05:00
james.maes 4eb5b70ade Merge pull request #17 from QRun-IO/feature/object-storage-rc4
feat(attachments): bucket upload cap via object storage api (RC.4)
2026-10-14 09:58:45 -05:00
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 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
15 changed files with 709 additions and 20 deletions

No files matched your search

+6
View File
@@ -4,6 +4,12 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
## [Unreleased]
### Changed
- Switch the optional qbit-email integration to 0.1.0-RC.2 and its published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and enqueue-status handling. Narrow the email package allowance to the public API.
- 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.
+14 -1
View File
@@ -269,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. |
@@ -277,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.2 is an optional dependency (`<optional>true</optional>`). The integration uses its published `com.kingsrook.qbits.email.api` package for installation detection, template registration and sending. 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`. Without qbit-email nothing changes: `NoOpNotifier` sends nothing. A configured `notifier` always wins.
The guardrail allowlist properties retain the object-storage API and `*-api` artifact allowances and add only the qbit-email artifact and `com.kingsrook.qbits.email.api` package. The current parent 2.0.0 does not execute guardrails; these properties are ready for a parent that does. Tests retain `EmailQBitProducer`/`EmailQBitConfig` only to assemble the host instance; G1 checks production classes and sources.
- 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.
+17 -2
View File
@@ -22,12 +22,14 @@
<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.2</qbit-email.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -74,12 +76,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.
*******************************************************************************/
@@ -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,256 @@
/*
* 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.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
import com.kingsrook.qbits.email.api.EmailApiStatus;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
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 (EmailApi.isInstalled(qInstance));
}
/*******************************************************************************
** 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)
{
EmailApiTemplate 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;
}
EmailApi.ensureTemplate(template);
String eventId = n.sourceId() != null ? n.sourceId() : n.sourceRecordId();
EmailApiRequest request = new EmailApiRequest().withTemplateName(template.getName()).withCategory(EmailApiCategory.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());
}
EmailApiResult result = EmailApi.send(request);
if(result.status() != EmailApiStatus.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 EmailApiTemplate 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 EmailApiTemplate definition(String name, String label, String subject, String body)
{
return (new EmailApiTemplate().withName(name).withLabel(label).withCategory(EmailApiCategory.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 EmailApiTemplate 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 EmailApiTemplate 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 EmailApiTemplate 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));
}
}
@@ -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.api.EmailApi");
return (EmailCollaborationNotifier.isAvailable(QContext.getQInstance()));
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
/*******************************************************************************
** Relative canonical frontend link with separately encoded path segments.
*******************************************************************************/
@@ -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);
@@ -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()));
}
}
}
@@ -0,0 +1,258 @@
/*
* 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.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.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiMessage;
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.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"));
}
/*******************************************************************************
** Host fixture wiring only: G1 scans production classes and sources. Create
** the provider through standard QQQ actions with the documented permissions.
*******************************************************************************/
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("email.view", "email.send", "email.manage", "email.admin", "email.hasAccess");
QRecord 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);
assertThat(new InsertAction().execute(new InsertInput("email_provider").withRecords(List.of(provider))).getRecords())
.singleElement().satisfies(row -> assertThat(row.getErrors()).isNullOrEmpty());
}
/*******************************************************************************
** Inspect rendered test content through the standard, permission-checked
** table action. The public API deliberately exposes only message metadata.
*******************************************************************************/
private List<QRecord> messages() throws Exception
{
return (new QueryAction().execute(new QueryInput("email_message").withFilter(new QQueryFilter())
.withShouldFetchHeavyFields(true).withShouldOmitHiddenFields(false)).getRecords());
}
/*******************************************************************************
** 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(EmailApi.getMessageByIdempotencyKey("collaboration:mention:7:bob")).hasValueSatisfying(message ->
{
assertThat(message.status()).isEqualTo("QUEUED");
assertThat(message.category()).isEqualTo(EmailApiCategory.NOTIFICATION);
assertThat(message.templateName()).isEqualTo("collaboration_mention");
assertThat(message.linkedTableName()).isEqualTo("document");
assertThat(message.linkedRecordId()).isEqualTo("1");
});
}
/*******************************************************************************
** 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(EmailApi.findMessagesByIdempotencyKeyPrefix("collaboration:", 10)).extracting(EmailApiMessage::templateName)
.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();
}
}