feat(email): email mentions, shares and task notices through qbit-email #15
No files matched your search
@@ -4,6 +4,10 @@ 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.
|
||||
|
||||
@@ -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.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.
|
||||
|
||||
@@ -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>
|
||||
@@ -80,6 +86,19 @@
|
||||
<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);
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
+257
@@ -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));
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
+59
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+242
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user