Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 e014c00428 feat: QQQ 4.1.0-RC.7: record followers provider and secured-data label rules
CollaborationFollowersProvider implements core RecordFollowersProviderInterface
over collaboration_follow (CHANGE/ALERT -> notifyOnChange, COMMENT ->
notifyOnComment, muted excluded), registered via RecordFeaturesMetaData, so
qbit-alerts can notify followers. Cached favorite/recent labels are stored
only when FieldAccessHelper.isRecordLabelFullAccess holds; notification
titles fall back to the table label and id for recipients without FULL
label access. Timeline masking (MASKED shows the mask, HIDDEN omitted,
canary never leaks) is covered by tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:29:09 -05:00
james.maes ea0b74f94c Merge pull request #22 from QRun-IO/fix/issues-20-21
fix: context-free attachment steps (#20) and no fixed home column widths (#21)
2026-10-14 14:59:18 -05:00
james.maesandClaude Opus 5.5 e051c74273 fix: context-free attachment action steps and no fixed home column widths
DeleteAttachmentsProcess Review/Delete create their AttachmentsService on
first use instead of in the constructor, so QQQ can build them during
context-free instance validation (fixes #20). Home table columns no
longer set widths, so narrow cards wrap instead of scrolling (fixes #21).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:56:09 -05:00
james.maes 680fd5fa02 Merge pull request #18 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:56:55 -05:00
james.maes 1e0a5773bb Merge pull request #19 from QRun-IO/feature/email-api
refactor(email): use qbit-email's public api package; no G1 allow-list
2026-10-14 10:25:07 -05:00
james.maesandClaude Opus 5.5 83b253a94f ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:06:36 -05:00
22 changed files with 566 additions and 48 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
+6 -1
View File
@@ -6,6 +6,11 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Changed
- Upgrade the default QQQ BOM to `4.1.0-RC.7` from Maven Central.
- Register the core followers provider for alerts and other qbits, with event preferences, future-mute exclusion, system reads scoped to one record, and caller-owned recipient access checks.
- Cache favorite/recent record labels only for FULL field access. Resolve comment/share notification labels per recipient with a table-label-and-id fallback. Use the typed core label-access API for email labels as well.
- Verify RC.7 timeline audit masking and HIDDEN-field omission through enforced viewer queries, including canaries in values and messages.
- 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`.
@@ -38,7 +43,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
- Adopt core feature overrides, record capabilities and implied WRITE locks, reference metadata, mirrored locks, after-commit cleanup and summaries. Core registers feature permissions in the policy editor.
- Replace `OtherUserAccess`/`UserSessionSupplier` with `CollaborationRecordAccessChecker` and core `UserSessionProviderInterface`; configure `withUserSessionProvider`.
- Remove `ParentTableRestriction` and `TagFilterTranslator`; runtime `collaborationTags` filtering/search uses core subqueries. Retain the unpinned visibility fallback still missing from core.
- Publish identifier-only realtime hints after commit. Selectable Tags output and the optional followers SPI await missing core hooks.
- Publish identifier-only realtime hints after commit. Selectable Tags output awaits a core hook; the followers SPI is adopted above.
- Build against locally installed QQQ `4.1.0-SNAPSHOT` until RC.6 is released; `qqq-middleware-javalin` is a compile dependency. Object storage remains pinned to `0.1.0-RC.2`.
+23 -6
View File
@@ -11,16 +11,33 @@ Comment parents list the selected thread's root and replies. Feature-setting sco
## Requirements
- Java 21.
- QQQ `4.1.0-SNAPSHOT` installed from develop, including UI API extension point #1128. The build will move to `4.1.0-RC.6` once released; middleware Javalin is a compile dependency.
- QQQ `4.1.0-RC.7` from Maven Central; middleware Javalin is a compile dependency.
- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use the memory backend.
- Attachments require `qbit-object-storage:0.1.0-RC.2` (a pinned dependency), installed with a PRIVATE bucket alias matching `attachmentsBucketName`. Configure its connection and credentials in the host; this qbit does not provision buckets.
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-SNAPSHOT`, targeting RC.6.
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-RC.7`.
## Followers and secured data
The instance registers a core `RecordFollowersProviderInterface` backed by
`collaboration_follow`, so alerts and other qbits can use `RecordFollowers.get`.
CHANGE and ALERT select `notifyOnChange`; COMMENT selects `notifyOnComment`.
Future mutes are excluded, and disabling FOLLOW returns no subscribers. The
provider reads only the requested table/record as the system. Consumers must
check each recipient's record access before sending data.
Favorites and recents cache a record label only when every label field is FULL
for the current session; otherwise the stored label is null. Lists continue to
re-read labels as the viewer. Comment and share notification titles resolve
labels in each recipient's session and fall back to the table label and id
(for example, `Order #1042`) when label access is restricted or unavailable.
Timeline audit reads use core's enforced query boundary: MASKED values are
masked, HIDDEN details are omitted, and restricted audit messages are cleared.
Comment bodies remain user content; realtime field-name filtering is handled by core.
## Demo server
`CollaborationDevServer` is a disposable, memory-backed Sales CRM for trying the
Next dashboard and taking screenshots. It needs Java 21 and the locally installed
QQQ `4.1.0-SNAPSHOT` from develop. No database, Docker or MinIO is needed. It binds
Next dashboard and taking screenshots. It needs Java 21 and QQQ `4.1.0-RC.7` from Maven Central. No database, Docker or MinIO is needed. It binds
to `127.0.0.1`; stopping the JVM discards edits and uploaded attachments.
Build the Next static export in `qqq-frontend-next`:
@@ -563,7 +580,7 @@ host integrations as other collaboration tables.
## Core contracts and completed UI API
The qbit targets QQQ RC.6 using the locally installed `4.1.0-SNAPSHOT`. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata:
The qbit uses QQQ `4.1.0-RC.7` from Maven Central. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata:
```java
config.withDefaultFeatures(RecordFeature.FAVORITES, RecordFeature.RECENTS, RecordFeature.TIMELINE)
@@ -595,7 +612,7 @@ New routes, all under `/qqq/v1/qbit/collaboration`:
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension stays at unreleased **1.0.0**; contract tooling accepts these additions without a bump.
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. The optional followers provider is skipped because `RecordFollowersProviderInterface` is absent from the installed jar. Selectable Tags values remain unavailable because the overlay has no output hook.
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. `CollaborationFollowersProvider` supplies the core followers SPI for alerts and other consumers. Selectable Tags values remain unavailable because the overlay has no output hook.
## Custom fields (phase f)
+2 -2
View File
@@ -196,14 +196,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -68,6 +68,7 @@ public final class CoreCollaboration
{
config.applyFeatures(instance);
var features = RecordFeaturesMetaData.ofOrWithNew(instance);
features.withFollowersProvider(new QCodeReference(com.kingsrook.qbits.collaboration.follow.CollaborationFollowersProvider.class));
features.withOverrideProvider(new QCodeReference(FeatureSettingsProvider.class));
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.tags.TagsMetaDataProvider.class));
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.CustomFieldsProvider.class));
@@ -35,6 +35,7 @@ import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -55,7 +56,7 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Resolves targets only with released RC.5 APIs. Each list batches record label
** Resolves targets through core permission and field-access APIs. Each list batches record label
** refreshes by table, preserving table permissions and record security locks.
** Next accepts the flat /app/name/ routes, including dotted process names.
*******************************************************************************/
@@ -131,6 +132,17 @@ public final class TargetResolver
/*******************************************************************************
** Persist record labels only when all label fields are FULL for this session.
*******************************************************************************/
public static String cacheableLabel(FavoriteEntry target)
{
return ("RECORD".equals(target.targetType())
&& !FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(target.targetName())) ? null : target.label());
}
/*******************************************************************************
** One QueryAction per referenced record table; no cached labels escape.
*******************************************************************************/
@@ -200,7 +200,7 @@ public final class CollaborationHome
row.put("records", tag.getValueInteger("usageCount") == null ? 0 : tag.getValueInteger("usageCount"));
rows.add(row);
}
return (table("Tags", NO_TAGS, rows, column("Label", "label", "3fr", "left"), column("Table", "table", "2fr", "left"), column("Records", "records", "1fr", "right")));
return (table("Tags", NO_TAGS, rows, column("Label", "label", "left"), column("Table", "table", "left"), column("Records", "records", "right")));
}
@@ -224,8 +224,8 @@ public final class CollaborationHome
row.put("indexState", friendly(definition.getValueString("indexState")));
rows.add(row);
}
return (table("Custom fields", NO_FIELDS, rows, column("Name", "name", "3fr", "left"), column("Table", "table", "2fr", "left"),
column("Status", "status", "1fr", "left"), column("Index state", "indexState", "1fr", "left")));
return (table("Custom fields", NO_FIELDS, rows, column("Name", "name", "left"), column("Table", "table", "left"),
column("Status", "status", "left"), column("Index state", "indexState", "left")));
}
@@ -249,7 +249,7 @@ public final class CollaborationHome
rows.add(row);
});
}
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "3fr", "left"), column("People", "viewers", "1fr", "right")));
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right")));
}
@@ -267,9 +267,12 @@ public final class CollaborationHome
/*******************************************************************************
** A plain column; the frontend styles it.
*******************************************************************************/
private static TableData.Column column(String header, String accessor, String width, String align)
private static TableData.Column column(String header, String accessor, String align)
{
return (new TableData.Column("default", header, accessor, width, align));
//////////////////////////////////////////////////////////////////
// no width: frontends size the columns to fit narrow (390 px) cards //
//////////////////////////////////////////////////////////////////
return (new TableData.Column("default", header, accessor, null, align));
}
@@ -92,7 +92,7 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final AttachmentsService service;
private AttachmentsService service;
private final ProcessSummaryLine selected = line(Status.OK, "will be deleted", "deleted");
private final ProcessSummaryLine skipped = line(Status.WARNING, "cannot be deleted (skipped)", "skipped");
@@ -101,7 +101,11 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public Review()
{
this(new AttachmentsService());
//////////////////////////////////////////////////////////////////////////
// no QContext here: QQQ constructs steps during context-free instance //
// validation, so the service is created on first use (in a run) //
//////////////////////////////////////////////////////////////////////////
this(null);
}
/*******************************************************************************
@@ -112,6 +116,20 @@ public final class DeleteAttachmentsProcess
this.service = service;
}
/*******************************************************************************
** The attachments service, created on first use inside a run (QContext exists).
*******************************************************************************/
private AttachmentsService service()
{
if(service == null)
{
service = new AttachmentsService();
}
return (service);
}
/*******************************************************************************
** Dry run only; no object or row mutations.
*******************************************************************************/
@@ -122,7 +140,7 @@ public final class DeleteAttachmentsProcess
{
try
{
QRecord checked = service.requireMutable(row.getValue("id"));
QRecord checked = service().requireMutable(row.getValue("id"));
output.getRecords().add(checked);
selected.incrementCountAndAddPrimaryKey(checked.getValue("id"));
}
@@ -151,7 +169,7 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public static class Delete extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final AttachmentsService service;
private AttachmentsService service;
private final ProcessSummaryLine deleted = line(Status.OK, "will be deleted", "deleted");
private final ProcessSummaryLine failed = line(Status.ERROR, "could not be deleted", "could not be deleted (retry required)");
@@ -160,7 +178,11 @@ public final class DeleteAttachmentsProcess
*******************************************************************************/
public Delete()
{
this(new AttachmentsService());
//////////////////////////////////////////////////////////////////////////
// no QContext here: QQQ constructs steps during context-free instance //
// validation, so the service is created on first use (in a run) //
//////////////////////////////////////////////////////////////////////////
this(null);
}
/*******************************************************************************
@@ -171,6 +193,20 @@ public final class DeleteAttachmentsProcess
this.service = service;
}
/*******************************************************************************
** The attachments service, created on first use inside a run (QContext exists).
*******************************************************************************/
private AttachmentsService service()
{
if(service == null)
{
service = new AttachmentsService();
}
return (service);
}
/*******************************************************************************
** Count success only after both storage and row deletion succeed.
*******************************************************************************/
@@ -181,7 +217,7 @@ public final class DeleteAttachmentsProcess
{
try
{
service.delete(List.of(row.getValue("id")));
service().delete(List.of(row.getValue("id")));
deleted.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
catch(QException | RuntimeException expected)
@@ -288,7 +288,7 @@ public class CommentsService
new FollowService().ensureFollower(user, table, id, "MENTIONED");
if(!user.equals(PersonalRecords.currentUserId()))
{
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment", mention.getValueInteger("id"));
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment on ", mention.getValueInteger("id"));
notified.add(user);
}
}
@@ -307,7 +307,7 @@ public class CommentsService
if(!author.equals(follower.userId()) && !mentioned.contains(follower.userId()) && Boolean.TRUE.equals(follower.notifyOnComment())
&& (follower.mutedUntil() == null || !follower.mutedUntil().isAfter(Instant.now())))
{
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on a followed record", null);
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on ", null);
}
}
}
@@ -317,7 +317,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,
Notifications.send(new CollaborationNotification(user, title
+ new CollaborationRecordAccessChecker().notificationLabel(user, row.getValueString("tableName"), row.getValueString("recordId")), row.getValueString("body"), category,
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,
@@ -70,7 +70,7 @@ public final class FavoritesService
.withOrderBy(new QFilterOrderBy("sortOrder", false)).withLimit(1));
Integer order = last.isEmpty() ? 0 : Math.addExact(last.getFirst().getValueInteger("sortOrder"), 1);
QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName)
.withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("sortOrder", order);
.withValue("recordId", target.recordId()).withValue("label", TargetResolver.cacheableLabel(target)).withValue("sortOrder", order);
try
{
PersonalRecords.insert(CollaborationTables.FAVORITE, row);
@@ -0,0 +1,70 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.follow;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowersProviderInterface;
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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Core event subscribers; consumers must check each recipient's record access.
*******************************************************************************/
public class CollaborationFollowersProvider implements RecordFollowersProviderInterface
{
/*******************************************************************************
** Read only this parent's subscriptions as the system, independent of the caller.
*******************************************************************************/
@Override
public List<RecordFollower> getFollowers(QInstance instance, RecordReference reference, FollowEvent event) throws QException
{
List<RecordFollower> followers = new ArrayList<>();
QContext.withTemporaryContext(new CapturedContext(instance, new QSystemUserSession(instance)), () ->
{
Instant now = Instant.now();
String preference = event == FollowEvent.COMMENT ? "notifyOnComment" : "notifyOnChange";
for(QRecord row : CollaborationRecords.query(CollaborationTables.FOLLOW,
CollaborationRecords.parent(reference.tableName(), reference.primaryKey().toString())))
{
if(Boolean.TRUE.equals(row.getValueBoolean(preference))
&& (row.getValueInstant("mutedUntil") == null || !row.getValueInstant("mutedUntil").isAfter(now)))
{
followers.add(new RecordFollower().withUserId(row.getValueString("userId"))
.withNotifyOnChange(row.getValueBoolean("notifyOnChange")).withNotifyOnComment(row.getValueBoolean("notifyOnComment"))
.withReason(row.getValueString("reason")));
}
}
});
return (followers);
}
}
@@ -70,7 +70,7 @@ public final class RecentsService
if(existing.isEmpty())
{
QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName)
.withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("lastViewedAt", now).withValue("viewCount", 1);
.withValue("recordId", target.recordId()).withValue("label", TargetResolver.cacheableLabel(target)).withValue("lastViewedAt", now).withValue("viewCount", 1);
try
{
PersonalRecords.insert(CollaborationTables.RECENT, row);
@@ -92,7 +92,7 @@ public final class RecentsService
return;
}
PersonalRecords.update(CollaborationTables.RECENT, List.of(new QRecord().withValue("id", row.getValue("id"))
.withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", target.label())));
.withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", TargetResolver.cacheableLabel(target))));
}
@@ -27,6 +27,7 @@ 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.permissions.fieldaccess.FieldAccessHelper;
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;
@@ -34,7 +35,6 @@ 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;
/*******************************************************************************
@@ -90,8 +90,7 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
/*******************************************************************************
** 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).
** accessible under the recipient session's field-access policy.
*******************************************************************************/
public Optional<String> recordLabel(String userId, String table, Serializable recordId) throws QException
{
@@ -111,7 +110,7 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
try
{
ParentRecordAccess.requireVisibleParent(table, recordId);
if(labelFullyAccessible(table))
if(FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(table)))
{
GetInput input = new GetInput(table).withPrimaryKey(recordId).withShouldGenerateDisplayValues(true);
QRecord record = new GetAction().executeForRecord(input);
@@ -127,18 +126,10 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
}
/*******************************************************************************
** Reflective guard: the helper only exists in QQQ versions with secured data.
** Notification titles fall back to public table metadata and the record id.
*******************************************************************************/
private static boolean labelFullyAccessible(String table)
public String notificationLabel(String userId, String table, Serializable recordId) throws QException
{
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);
}
return (recordLabel(userId, table, recordId).orElse(QContext.getQInstance().getTable(table).getLabel() + " #" + recordId));
}
}
@@ -97,7 +97,8 @@ public class SharingService
.withValue("recipientCouldRead", recipient.canRead()).withValue("sentAt", Instant.now()).withValue("parentDeleted", false));
if(Boolean.TRUE.equals(recipient.canRead()))
{
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared " + link.label() + " with you", message,
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared "
+ new CollaborationRecordAccessChecker().notificationLabel(recipient.userId(), table, id) + " with you", message,
CollaborationNotification.Category.SHARE, table, id, link.url()).withEvent(row.getValue("id"), from));
new FollowService().ensureFollower(recipient.userId(), table, id, "SHARED");
}
@@ -228,6 +228,21 @@ class CollaborationTest
/*******************************************************************************
** Home tables leave column widths to the frontend, so narrow cards wrap
** instead of scrolling sideways (issue #21).
*******************************************************************************/
@Test
void homeTablesHaveNoFixedColumnWidths() throws Exception
{
for(TableData table : java.util.List.of(CollaborationHome.tags(), CollaborationHome.fields(), CollaborationHome.presence()))
{
assertThat(table.getColumns()).isNotEmpty().allSatisfy(column -> assertThat(column.getWidth()).isNull());
}
}
/*******************************************************************************
** Empty states explain what has not been populated.
*******************************************************************************/
@@ -179,8 +179,9 @@ public class CustomFieldsTest extends PersonalServiceFixture
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(CollaborationTables.FIELD_DEFINITION).withPrimaryKeys(List.of(row.getValue("id"))))).hasMessageContaining("archive");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("name", "renamed"), row)).hasMessageContaining("cannot change");
assertThatThrownBy(() -> FieldDefinitions.options(new QRecord().withValue("options", "{}"))).hasMessageContaining("options");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("classification", "BAD"), row)).hasMessageContaining("classification");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("minValue", 5).withValue("maxValue", 1), row)).hasMessageContaining("Minimum");
QRecord published = FieldDefinitions.get(row.getValue("id"));
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(published).withValue("classification", "BAD"), published)).hasMessageContaining("classification");
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(published).withValue("minValue", 5).withValue("maxValue", 1), published)).hasMessageContaining("Minimum");
}
/*******************************************************************************
@@ -0,0 +1,109 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowers;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Exercise the installed provider through core's public event-subscriber facade.
*******************************************************************************/
class FollowersProviderTest extends PersonalServiceFixture
{
/*******************************************************************************
** Preferences, mutes and parent scope apply even for callers without access.
*******************************************************************************/
@Test
void resolvesEventSubscribersAsSystemWithoutFilteringRecipientAccess() throws Exception
{
seed("change", "document", "1", true, false, null, false);
seed("comment", "document", "1", false, true, null, false);
seed("both", "document", "1", true, true, Instant.now().minusSeconds(60), false);
seed("muted", "document", "1", true, true, Instant.now().plusSeconds(3600), false);
seed("neither", "document", "1", false, false, null, false);
seed("other-record", "document", "2", true, true, null, false);
seed("other-table", "other", "1", true, true, null, false);
seed("deleted", "document", "1", true, true, null, true);
RecordReference reference = new RecordReference("document", 1);
for(QSession session : List.of(new QSession(), new QSystemUserSession(instance)))
{
QContext.init(instance, session);
for(FollowEvent event : List.of(FollowEvent.ALERT, FollowEvent.CHANGE))
{
assertThat(RecordFollowers.get(instance, reference, event)).extracting(RecordFollower::getUserId)
.containsExactlyInAnyOrder("change", "both");
}
assertThat(RecordFollowers.get(instance, reference, FollowEvent.COMMENT)).extracting(RecordFollower::getUserId)
.containsExactlyInAnyOrder("comment", "both");
assertThat(RecordFollowers.get(instance, reference, FollowEvent.CHANGE)).filteredOn(f -> f.getUserId().equals("change"))
.singleElement().satisfies(f ->
{
assertThat(f.getNotifyOnChange()).isTrue();
assertThat(f.getNotifyOnComment()).isFalse();
assertThat(f.getReason()).isEqualTo("MANUAL");
});
assertThat(QContext.getQSession()).isSameAs(session);
}
}
/*******************************************************************************
** Existing subscriptions do not override the host's FOLLOW feature switch.
*******************************************************************************/
@Test
void disabledFollowReturnsEmpty() throws Exception
{
seed("follower", "document", "1", true, true, null, false);
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.FOLLOW, false);
RuntimeMetaDataOverlay.refresh(instance);
for(FollowEvent event : FollowEvent.values())
{
assertThat(RecordFollowers.get(instance, new RecordReference("document", 1), event)).isEmpty();
}
}
/*******************************************************************************
** Seed persisted preferences directly, including subscriptions lacking access.
*******************************************************************************/
private void seed(String user, String table, String id, Boolean change, Boolean comment, Instant muted, Boolean deleted) throws Exception
{
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.FOLLOW).withRecords(List.of(new QRecord()
.withValue("userId", user).withValue("tableName", table).withValue("recordId", id).withValue("reason", "MANUAL")
.withValue("notifyOnChange", change).withValue("notifyOnComment", comment).withValue("mutedUntil", muted)
.withValue("parentDeleted", deleted))), true);
}
}
@@ -0,0 +1,159 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.comments.CommentsService;
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
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.notify.RecordingNotifier;
import com.kingsrook.qbits.collaboration.recents.RecentsService;
import com.kingsrook.qbits.collaboration.sharing.SharingService;
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Cached and delivered labels respect the session that will receive the data.
*******************************************************************************/
class SecuredLabelsTest extends PersonalServiceFixture
{
private static RecordingNotifier recorder;
/*******************************************************************************
** Non-FULL labels are never cached; lists always re-read as the current viewer.
*******************************************************************************/
@Test
void cachesOnlyFullAccessLabelsAndRefreshesForViewer() throws Exception
{
for(FieldSecurityLock.Behavior behavior : List.of(FieldSecurityLock.Behavior.MASK, FieldSecurityLock.Behavior.DENY, FieldSecurityLock.Behavior.REVEAL))
{
restrictLabel(behavior);
FavoritesService.star("RECORD", "document", 1);
RecentsService.recordView("RECORD", "document", 1);
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
{
assertThat(PersonalRecords.query(table, PersonalRecords.ownFilter())).singleElement()
.satisfies(row -> assertThat(row.getValueString("label")).isNull());
}
assertThat(FavoritesService.list()).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
assertThat(RecentsService.list(50)).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
FavoritesService.unstar("RECORD", "document", 1);
PersonalRecords.delete(CollaborationTables.RECENT, PersonalRecords.query(CollaborationTables.RECENT, PersonalRecords.ownFilter()).stream().map(row -> row.getValue("id")).toList());
}
QContext.getQSession().withSecurityKeyValue("sensitive", "full");
FavoritesService.star("RECORD", "document", 1);
RecentsService.recordView("RECORD", "document", 1);
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
{
assertThat(PersonalRecords.query(table, PersonalRecords.ownFilter())).singleElement()
.satisfies(row -> assertThat(row.getValueString("label")).isEqualTo("First document"));
}
login("alice");
assertThat(FavoritesService.list()).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
assertThat(RecentsService.list(50)).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
FavoritesService.star("TABLE", "document", null);
assertThat(PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.targetFilter("TABLE", "document", null)))
.singleElement().satisfies(row -> assertThat(row.getValueString("label")).isEqualTo("Documents"));
}
/*******************************************************************************
** The sender's FULL label cannot leak to recipients with MASKED or HIDDEN access.
*******************************************************************************/
@Test
void notificationTitlesResolveLabelsPerRecipient() throws Exception
{
for(FieldSecurityLock.Behavior behavior : List.of(FieldSecurityLock.Behavior.MASK, FieldSecurityLock.Behavior.DENY))
{
restrictLabel(behavior);
QContext.getQSession().withSecurityKeyValue("sensitive", "full");
recorder = new RecordingNotifier();
config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionProvider(new QCodeReference(TestSessions.class));
new SharingService().share("document", 1, List.of("restricted", "full"), "Shared user content");
new CommentsService().post("document", 1, "@[Restricted](user:restricted) @[Full](user:full)", null, null);
assertThat(recorder.notifications()).hasSize(4);
assertThat(recorder.notifications()).filteredOn(n -> n.recipientUserId().equals("restricted")).hasSize(2)
.allSatisfy(n -> assertThat(n.title()).contains("Documents #1").doesNotContain("First document"));
assertThat(recorder.notifications()).filteredOn(n -> n.recipientUserId().equals("full")).hasSize(2)
.allSatisfy(n -> assertThat(n.title()).contains("First document"));
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo("alice");
}
}
/*******************************************************************************
** Field-lock override grants FULL to selected sessions only.
*******************************************************************************/
private void restrictLabel(FieldSecurityLock.Behavior behavior)
{
if(!instance.getSecurityKeyTypes().containsKey("sensitive"))
{
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
}
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(behavior).withOverrideValues(List.of("full")));
}
/*******************************************************************************
** Recipients share record access but have different label access.
*******************************************************************************/
public static class TestSessions implements UserSessionProviderInterface
{
/*******************************************************************************
** Build independent recipient sessions for real core field checks.
*******************************************************************************/
@Override
public Optional<QSession> sessionForUser(String userId)
{
QSession session = new QSession().withUser(new QUser().withIdReference(userId)).withPermissions("document.read")
.withSecurityKeyValue("tenant", "a");
if(userId.equals("full"))
{
session.withSecurityKeyValue("sensitive", "full");
}
return (Optional.of(session));
}
}
/*******************************************************************************
** Capture notifications before any external transport.
*******************************************************************************/
public static class TestNotifier implements CollaborationNotifier
{
/*******************************************************************************
** Preserve the payload for assertions.
*******************************************************************************/
@Override
public void notify(CollaborationNotification notification)
{
recorder.notify(notification);
}
}
}
@@ -32,6 +32,10 @@ import com.kingsrook.qbits.collaboration.timeline.TimelinePage;
import com.kingsrook.qbits.collaboration.timeline.TimelineService;
import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldMaskStyle;
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldValueMasker;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.metadata.personalization.TableMetaDataPersonalizerInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
@@ -182,6 +186,40 @@ public class TimelineServiceTest extends PersonalServiceFixture
assertThat(timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items()).isEmpty();
}
/*******************************************************************************
** RC.7 QueryAction masks audit values and removes HIDDEN detail rows as the viewer.
*******************************************************************************/
@Test
void fieldTiersProtectTimelineValuesAndMessages() throws Exception
{
String canary = "TIMELINE-SECRET-CANARY";
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK).withMaskStyle(FieldMaskStyle.FULL));
instance.getTable("document").getField("tenant").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.DENY));
seedAudit(4, canary, 100);
for(String field : List.of("title", "tenant"))
{
seed("auditDetail", new QRecord().withValue("id", field.equals("title") ? 10L : 11L).withValue("auditId", 4L)
.withValue("fieldName", field).withValue("oldValue", canary).withValue("newValue", canary).withValue("message", canary));
}
TimelinePage page = timeline.timeline("document", 1, null, 50, Set.of("AUDIT"));
assertThat(page.items()).filteredOn(item -> item.id().equals(4L)).singleElement().satisfies(item ->
{
assertThat(item.summary()).isNull();
assertThat(item.details()).singleElement().satisfies(detail ->
{
assertThat(detail.fieldName()).isEqualTo("title");
assertThat(detail.oldValue()).isEqualTo(FieldValueMasker.MASK);
assertThat(detail.newValue()).isEqualTo(FieldValueMasker.MASK);
assertThat(detail.message()).isNull();
});
});
assertThat(page.items().stream().flatMap(item -> item.details().stream())).noneMatch(detail -> "tenant".equals(detail.fieldName()));
assertThat(page.toString()).doesNotContain(canary, "Old", "New", "sensitive old", "sensitive new");
}
/*******************************************************************************
** Real personalizer boundary, like core's audit screen.
*******************************************************************************/
@@ -102,6 +102,20 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
/*******************************************************************************
** Upload, metadata, newest-first list, signed download and deletion round trip.
*******************************************************************************/
/*******************************************************************************
** The action steps can be constructed without a QContext, as QQQ does during
** instance validation (issue #20); the service is created on first use.
*******************************************************************************/
@Test
void stepsConstructWithoutContext()
{
QContext.clear();
assertThat(new DeleteAttachmentsProcess.Review()).isNotNull();
assertThat(new DeleteAttachmentsProcess.Delete()).isNotNull();
}
@Test
void roundTrip() throws Exception
{
@@ -43,6 +43,8 @@ import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderI
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.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
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;
@@ -159,7 +161,7 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
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")).contains("alice mentioned you on First document");
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");
});
@@ -175,6 +177,27 @@ class EmailCollaborationNotifierTest extends PersonalServiceFixture
/*******************************************************************************
** Restricted record labels never enter email subjects or rendered bodies.
*******************************************************************************/
@Test
void maskedLabelUsesGenericEmailText() throws Exception
{
withEmail();
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK));
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
assertThat(messages()).singleElement().satisfies(message ->
{
assertThat(message.getValueString("subject")).contains("alice mentioned you on a record in Documents");
assertThat(message.getValueString("subject") + message.getValueString("textBody") + message.getValueString("htmlBody"))
.doesNotContain("First document", "secret comment body");
});
}
/*******************************************************************************
** Each recipient gets their own message; the share and task kinds use their
** own templates and key kinds; follow digests send no email.
@@ -52,6 +52,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@@ -114,6 +116,26 @@ class RecentsServiceTest extends PersonalServiceFixture
/*******************************************************************************
** A refreshed recent discards its old cached label after access is restricted.
*******************************************************************************/
@Test
void refreshClearsLabelWhenFieldAccessIsRestricted() throws Exception
{
at(START);
RecentsService.recordView("RECORD", "document", 1);
assertThat(rows().getFirst().getValueString("label")).isEqualTo("First document");
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK));
at(START.plusSeconds(60));
RecentsService.recordView("RECORD", "document", 1);
assertThat(rows().getFirst().getValueString("label")).isNull();
assertThat(rows().getFirst().getValueInteger("viewCount")).isEqualTo(2);
}
/*******************************************************************************
** Null ids also upsert; visibility is checked even inside the throttle window.
*******************************************************************************/