Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b425ead1f | ||
|
|
08042c0f67
|
||
|
|
d302f1dc1f | ||
|
|
58c87b0378
|
||
|
|
e5647e6020
|
||
|
|
3d2a1ded72
|
||
|
|
3b12bacf4e | ||
|
|
2fbbf1006d
|
||
|
|
662a9f1a6d | ||
|
|
e014c00428
|
||
|
|
34d66c3c7e | ||
|
|
b243a0ad6a
|
||
|
|
ea0b74f94c | ||
|
|
e051c74273
|
||
|
|
680fd5fa02 | ||
|
|
1e0a5773bb | ||
|
|
83b253a94f | ||
|
|
01ee884546 |
No files matched your search
@@ -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:
|
||||
|
||||
+13
-1
@@ -4,8 +4,20 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Feature Settings table action **Create rows from configuration**: writes the code configuration out as editable `collaboration_feature_setting` rows (insert-only, only when the table is empty, no change in behavior).
|
||||
- Setup card on the Collaboration home: each record feature, whether it is on, for which tables, and where that comes from.
|
||||
- Help bundle `help/qbit-collaboration.yaml`: every table says what it is for and how its rows get created.
|
||||
- `CollaborationTablesGuardrailTest` and `FeatureSettingsDefaultsTest`.
|
||||
|
||||
### 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 +50,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`.
|
||||
|
||||
|
||||
@@ -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`:
|
||||
@@ -116,6 +133,15 @@ new CollaborationQBitProducer()
|
||||
|
||||
Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation after all producers.
|
||||
|
||||
## Starter defaults and setup
|
||||
|
||||
Collaboration ships no SQL seed. The one configuration table, `collaboration_feature_setting`, depends on the host's configuration (`withDefaultFeatures`, `withTableFeatures`, the default tag mode, features declared on apps and tables), so its rows are computed at run time:
|
||||
|
||||
- Open Feature Settings and run the table action **Create rows from configuration** (collaboration administrators). It writes one Instance row per feature, plus an App or Table row wherever the code declares something at that scope. The rows say exactly what the code already says, so nothing changes; `FeatureSettingsDefaultsTest` proves every table resolves identically with and without them.
|
||||
- It only creates rows when the table is empty, so it never touches an administrator's rows. Rows are applied after all code configuration. An Instance row overrides what code declares on any app or table that has no row of its own, so once rows exist, change features in the rows, not in code.
|
||||
- The **Setup** card on the Collaboration home shows each feature, whether it is on, for which tables, and whether that comes from Feature Settings rows or the configuration.
|
||||
- Every collaboration table has help (shown when it is empty) that says how its rows get created. The other tables hold user content or runtime data and start empty by design.
|
||||
|
||||
## Tables
|
||||
|
||||
| Table | Purpose |
|
||||
@@ -563,7 +589,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 +621,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)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased
|
||||
|
||||
No schema change and nothing required of hosts.
|
||||
|
||||
- New table action **Create rows from configuration** on Feature Settings. Run it once if you want the effective feature settings visible and editable as rows. It changes no behavior and does nothing once the table has any row. Note that after rows exist they win over later changes to `withDefaultFeatures` or `withTableFeatures` in code, so change those settings in the rows from then on.
|
||||
- New **Setup** widget first on the Collaboration home, and table help text. Hosts that override help on a table keep their own.
|
||||
@@ -18,7 +18,7 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<revision>0.1.0-RC.4</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
@@ -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>
|
||||
|
||||
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.attachments.DeleteAttachmentsProcess;
|
||||
import com.kingsrook.qbits.collaboration.metadata.CollaborationHelp;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.recents.TrimRecentsProcess;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -151,6 +152,7 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
com.kingsrook.qbits.collaboration.tags.TagProcesses.processesFor(table).forEach(output::add);
|
||||
}
|
||||
output.add(DeleteAttachmentsProcess.metadata());
|
||||
output.add(FeatureSettingsDefaultsProcess.process());
|
||||
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
|
||||
{
|
||||
output.add(source);
|
||||
@@ -178,6 +180,7 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
QAppMetaData parent = instance.getApp(qBitConfig.getParentAppName());
|
||||
parent.withChild(instance.getApp(qBitConfig.getAppName()));
|
||||
}
|
||||
CollaborationHelp.apply(instance, qBitConfig);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.model.metadata.collaboration.FeaturePermissionMode;
|
||||
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.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The collaboration_feature_setting rows that say, in the database, exactly
|
||||
** what the code configuration already says: one INSTANCE row per feature
|
||||
** (enabled for the configured default features, disabled for the rest, with
|
||||
** the default permission mode and the TAGS mode), plus an APP or TABLE row
|
||||
** wherever the code declares something at that scope.
|
||||
**
|
||||
** Why rows computed at run time and not a SQL seed: the right rows depend on
|
||||
** the host's configuration (withDefaultFeatures, withTableFeatures, the
|
||||
** default tag mode, features declared on apps and tables), which SQL cannot
|
||||
** see.
|
||||
**
|
||||
** Why every declaring scope gets its own row: runtime overrides are applied
|
||||
** AFTER the code declarations, instance first, then app, then table. A bare
|
||||
** INSTANCE row would therefore overwrite what a table declares in code.
|
||||
** Restating each declaring scope in its own row keeps the resolved result of
|
||||
** every table identical with and without the rows (FeatureSettingsDefaultsTest
|
||||
** proves that for several configurations).
|
||||
**
|
||||
** Rows are only ever created when the table is empty, so they can never
|
||||
** disagree with rows an administrator already wrote.
|
||||
*******************************************************************************/
|
||||
public final class FeatureSettingsDefaults
|
||||
{
|
||||
public static final String INSTANCE_SCOPE_NAME = "instance";
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private FeatureSettingsDefaults()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rows to create: the configured rows when the table is empty, none
|
||||
** when an administrator already has rows.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> missingRows(QInstance instance, CollaborationQBitConfig config) throws QException
|
||||
{
|
||||
if(!CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter().withLimit(1)).isEmpty())
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
return (configuredRows(instance, config));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every row the configuration implies (whether or not it exists yet).
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> configuredRows(QInstance instance, CollaborationQBitConfig config)
|
||||
{
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// instance: every feature, so each one has a visible row //
|
||||
///////////////////////////////////////////////////////////
|
||||
RecordFeaturesMetaData instanceLevel = RecordFeaturesMetaData.of(instance);
|
||||
for(RecordFeature feature : RecordFeature.values())
|
||||
{
|
||||
Map<String, Serializable> settings = new LinkedHashMap<>(declaredSettings(instanceLevel, feature));
|
||||
if(feature == RecordFeature.TAGS)
|
||||
{
|
||||
settings.putIfAbsent("mode", config.getDefaultTagMode());
|
||||
}
|
||||
FeaturePermissionMode mode = declaredMode(instanceLevel, feature);
|
||||
rows.add(row("INSTANCE", INSTANCE_SCOPE_NAME, feature, enabledThrough(feature, instanceLevel), mode == null ? FeaturePermissionMode.EDIT_OR_GRANTED : mode, settings));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// apps and tables: only where the code declares something for the feature //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
for(QAppMetaData app : CollectionUtils.nonNullMap(instance.getApps()).values())
|
||||
{
|
||||
RecordFeaturesMetaData level = RecordFeaturesMetaData.of(app);
|
||||
for(RecordFeature feature : declared(level))
|
||||
{
|
||||
rows.add(row("APP", app.getName(), feature, enabledThrough(feature, instanceLevel, level), modeThrough(feature, instanceLevel, level), declaredSettings(level, feature)));
|
||||
}
|
||||
}
|
||||
for(QTableMetaData table : CollectionUtils.nonNullMap(instance.getTables()).values())
|
||||
{
|
||||
if(table.getName().startsWith("collaboration_"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
RecordFeaturesMetaData level = RecordFeaturesMetaData.of(table);
|
||||
List<RecordFeaturesMetaData> apps = new ArrayList<>();
|
||||
for(QAppMetaData app : CollectionUtils.nonNullMap(instance.getApps()).values())
|
||||
{
|
||||
boolean contains = CollectionUtils.nonNullList(app.getSections()).stream().anyMatch(section -> CollectionUtils.nonNullList(section.getTables()).contains(table.getName()));
|
||||
if(contains && RecordFeaturesMetaData.of(app) != null)
|
||||
{
|
||||
apps.add(RecordFeaturesMetaData.of(app));
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// a table row restates the whole chain (instance, its apps, the table). //
|
||||
// Needed where the table declares something itself, and where several //
|
||||
// apps declare the same feature: one row per app cannot restate the //
|
||||
// combination, but the last override, the table row, can. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
Set<RecordFeature> features = new TreeSet<>(declared(level));
|
||||
if(apps.size() > 1)
|
||||
{
|
||||
apps.forEach(app -> features.addAll(declared(app)));
|
||||
}
|
||||
List<RecordFeaturesMetaData> chain = new ArrayList<>();
|
||||
chain.add(instanceLevel);
|
||||
chain.addAll(apps);
|
||||
chain.add(level);
|
||||
RecordFeaturesMetaData[] levels = chain.toArray(new RecordFeaturesMetaData[0]);
|
||||
for(RecordFeature feature : features)
|
||||
{
|
||||
Map<String, Serializable> settings = new LinkedHashMap<>();
|
||||
for(RecordFeaturesMetaData each : levels)
|
||||
{
|
||||
settings.putAll(declaredSettings(each, feature));
|
||||
}
|
||||
rows.add(row("TABLE", table.getName(), feature, enabledThrough(feature, levels), modeThrough(feature, levels), settings));
|
||||
}
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The features a scope declares anything about (enabled, mode or settings).
|
||||
*******************************************************************************/
|
||||
private static Set<RecordFeature> declared(RecordFeaturesMetaData level)
|
||||
{
|
||||
if(level == null)
|
||||
{
|
||||
return (Set.of());
|
||||
}
|
||||
return (Stream.of(level.getEnabled().keySet(), level.getPermissionModes().keySet(), level.getSettings().keySet())
|
||||
.flatMap(Set::stream).collect(Collectors.toCollection(TreeSet::new)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enabled as the code resolves it through the given levels, in order, the
|
||||
** same way core does (a later level that declares the feature wins).
|
||||
*******************************************************************************/
|
||||
private static boolean enabledThrough(RecordFeature feature, RecordFeaturesMetaData... levels)
|
||||
{
|
||||
boolean enabled = false;
|
||||
for(RecordFeaturesMetaData level : levels)
|
||||
{
|
||||
if(level != null && level.getEnabled().containsKey(feature))
|
||||
{
|
||||
enabled = Boolean.TRUE.equals(level.getEnabled().get(feature));
|
||||
}
|
||||
}
|
||||
return (enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission mode through the given levels (a later declaration wins),
|
||||
** EDIT_OR_GRANTED when none declares one, as core resolves it.
|
||||
*******************************************************************************/
|
||||
private static FeaturePermissionMode modeThrough(RecordFeature feature, RecordFeaturesMetaData... levels)
|
||||
{
|
||||
FeaturePermissionMode mode = FeaturePermissionMode.EDIT_OR_GRANTED;
|
||||
for(RecordFeaturesMetaData level : levels)
|
||||
{
|
||||
if(declaredMode(level, feature) != null)
|
||||
{
|
||||
mode = declaredMode(level, feature);
|
||||
}
|
||||
}
|
||||
return (mode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static FeaturePermissionMode declaredMode(RecordFeaturesMetaData level, RecordFeature feature)
|
||||
{
|
||||
return (level == null ? null : level.getPermissionModes().get(feature));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Serializable> declaredSettings(RecordFeaturesMetaData level, RecordFeature feature)
|
||||
{
|
||||
return (level == null || level.getSettings().get(feature) == null ? Map.of() : level.getSettings().get(feature));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One feature_setting record; no settings means a null settings column.
|
||||
*******************************************************************************/
|
||||
private static QRecord row(String scopeType, String scopeName, RecordFeature feature, boolean enabled, FeaturePermissionMode mode, Map<String, Serializable> settings)
|
||||
{
|
||||
QRecord record = new QRecord().withValue("scopeType", scopeType).withValue("scopeName", scopeName).withValue("feature", feature.name())
|
||||
.withValue("enabled", enabled).withValue("permissionMode", mode == null ? null : mode.name());
|
||||
if(!settings.isEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
record.withValue("settings", MAPPER.writeValueAsString(settings));
|
||||
}
|
||||
catch(JsonProcessingException e)
|
||||
{
|
||||
throw (new IllegalStateException("Feature settings are not serializable", e));
|
||||
}
|
||||
}
|
||||
return (record);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Create rows from configuration": a table action on Feature Settings that
|
||||
** writes the code configuration out as editable rows (FeatureSettingsDefaults).
|
||||
** It changes no behavior on its own, only makes the effective settings visible
|
||||
** and editable. It does nothing once the table has any row. Standard streamed
|
||||
** ETL: Review lists the rows that will be created, Result the rows created.
|
||||
*******************************************************************************/
|
||||
public final class FeatureSettingsDefaultsProcess
|
||||
{
|
||||
public static final String NAME = "collaborationCreateFeatureSettings";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private FeatureSettingsDefaultsProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The process: a table-level action (no records need to be selected).
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.FEATURE_SETTING);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.FEATURE_SETTING);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"These rows will be created from the current configuration. Nothing changes: the rows say what the configuration already says, and you can edit them afterwards.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
|
||||
process.setName(NAME);
|
||||
process.setLabel("Create rows from configuration");
|
||||
process.setIcon(new QIcon("playlist_add"));
|
||||
process.setTableName(CollaborationTables.FEATURE_SETTING);
|
||||
process.setMinInputRecords(0);
|
||||
process.setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("collaboration")
|
||||
.withCustomPermissionChecker(new QCodeReference(AdminOnly.class)));
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Collaboration administrators only (the table itself is admin-write).
|
||||
*******************************************************************************/
|
||||
public static class AdminOnly implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null || !session.hasPermission(CollaborationPermissions.ADMIN))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the " + CollaborationPermissions.ADMIN + " permission."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Extract: the configured rows (none when the table already has rows).
|
||||
*******************************************************************************/
|
||||
public static class Extract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer doCount(RunBackendStepInput input) throws QException
|
||||
{
|
||||
return (FeatureSettingsDefaults.missingRows(QContext.getQInstance(), CollaborationRecords.config()).size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord row : FeatureSettingsDefaults.missingRows(QContext.getQInstance(), CollaborationRecords.config()))
|
||||
{
|
||||
getRecordPipe().addRecord(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform: counts by scope for the review screen.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final Map<String, ProcessSummaryLine> byScope = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
String scope = record.getValueString("scopeType");
|
||||
byScope.computeIfAbsent(scope, s -> line(Status.OK, scope + " setting will be created", scope + " settings will be created",
|
||||
scope + " setting was created", scope + " settings were created")).incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
byScope.values().forEach(line -> line.addSelfToListIfAnyCount(lines));
|
||||
if(lines.isEmpty())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Feature settings already has rows, so nothing was created. Edit the rows directly."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load: insert through the standard action with the administrator's own session.
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine created = line(Status.OK, "setting was created", "settings were created", "setting was created", "settings were created");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One transaction for the whole load: the override refresh waits for the
|
||||
** commit, so a failure on a later page can never leave some rows (for
|
||||
** example Instance rows without the App and Table rows that restate the
|
||||
** code) in effect.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<QBackendTransaction> openTransaction(RunBackendStepInput input) throws QException
|
||||
{
|
||||
return (Optional.of(QBackendTransaction.openFor(new InsertInput(CollaborationTables.FEATURE_SETTING))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
List<QRecord> inserted = new InsertAction().execute(new InsertInput(CollaborationTables.FEATURE_SETTING).withRecords(input.getRecords())
|
||||
.withTransaction(getTransaction().orElse(null))).getRecords();
|
||||
for(QRecord record : inserted)
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not create feature settings: " + record.getErrors()));
|
||||
}
|
||||
created.incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
created.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A summary line with its four messages.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(singularFuture);
|
||||
line.setPluralFutureMessage(pluralFuture);
|
||||
line.setSingularPastMessage(singularPast);
|
||||
line.setPluralPastMessage(pluralPast);
|
||||
return (line);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -76,11 +76,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
*******************************************************************************/
|
||||
public final class CollaborationHome
|
||||
{
|
||||
public static final String SETUP_WIDGET = "collaborationSetup";
|
||||
public static final String KPI_WIDGET = "collaborationKpis";
|
||||
public static final String TAGS_WIDGET = "collaborationTags";
|
||||
public static final String FIELDS_WIDGET = "collaborationCustomFields";
|
||||
public static final String PRESENCE_WIDGET = "collaborationWhosHere";
|
||||
public static final List<String> HOME_WIDGETS = List.of(KPI_WIDGET, TAGS_WIDGET, FIELDS_WIDGET, PRESENCE_WIDGET);
|
||||
public static final List<String> HOME_WIDGETS = List.of(SETUP_WIDGET, KPI_WIDGET, TAGS_WIDGET, FIELDS_WIDGET, PRESENCE_WIDGET);
|
||||
|
||||
public static final String NOBODY_HERE = "Nobody else is here right now.";
|
||||
public static final String NO_TAGS = "No tags yet. Tags appear here once someone creates one on a record.";
|
||||
@@ -120,6 +121,7 @@ public final class CollaborationHome
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (List.of(
|
||||
widget(SETUP_WIDGET, "Setup", WidgetType.TABLE, SetupRenderer.class),
|
||||
widget(KPI_WIDGET, "Overview", WidgetType.MULTI_STATISTICS, KpiRenderer.class),
|
||||
widget(TAGS_WIDGET, "Tags", WidgetType.TABLE, TagsRenderer.class),
|
||||
widget(FIELDS_WIDGET, "Custom fields", WidgetType.TABLE, FieldsRenderer.class),
|
||||
@@ -140,6 +142,30 @@ public final class CollaborationHome
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each record feature, whether it is on, for which tables,
|
||||
** where that comes from, and the next step.
|
||||
*******************************************************************************/
|
||||
public static TableData setup() throws QException
|
||||
{
|
||||
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(CollaborationSetupStatus.Item item : CollaborationSetupStatus.load(QContext.getQInstance()))
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("feature", item.feature());
|
||||
row.put("state", item.state());
|
||||
row.put("where", item.where());
|
||||
row.put("source", item.source());
|
||||
row.put("next", item.nextStep() == null ? "" : item.nextStep());
|
||||
rows.add(row);
|
||||
}
|
||||
return (table("Setup", "Nothing to set up.", rows, column("Feature", "feature", "left"), column("State", "state", "left"), column("Where", "where", "left"),
|
||||
column("From", "source", "left"), column("Next step", "next", "left")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The KPI groups: conversations, work, files and custom fields.
|
||||
*******************************************************************************/
|
||||
@@ -200,7 +226,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 +250,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 +275,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 +293,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -360,6 +389,23 @@ public final class CollaborationHome
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
public static class SetupRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render using the current session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(setup()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** KPI renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.app;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Collaboration home: which record features are on, for
|
||||
** which tables, and whether that comes from Feature Settings rows or from the
|
||||
** code defaults. Uses the same resolution the runtime uses
|
||||
** (RecordFeaturesResolver: code declarations, then the committed rows), so the
|
||||
** card never reports something the runtime would not do.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationSetupStatus
|
||||
{
|
||||
private static final int NAMED_TABLES = 5;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String feature, String state, String where, String source, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line for Feature Settings itself, then one per record feature.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(QInstance instance) throws QException
|
||||
{
|
||||
List<QRecord> rows = CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter().withLimit(1000));
|
||||
Set<String> featuresWithRows = new HashSet<>();
|
||||
rows.forEach(row -> featuresWithRows.add(row.getValueString("feature")));
|
||||
|
||||
List<Item> items = new ArrayList<>();
|
||||
items.add(rows.isEmpty()
|
||||
? new Item("Feature settings", "Using code defaults", "-", "Configuration", "Run Create rows from configuration on Feature Settings to see and edit every setting.")
|
||||
: new Item("Feature settings", "Set up", rows.size() + " row" + (rows.size() == 1 ? "" : "s"), "Feature Settings", null));
|
||||
|
||||
List<QTableMetaData> hostTables = CollectionUtils.nonNullMap(instance.getTables()).values().stream()
|
||||
.filter(table -> !table.getName().startsWith("collaboration_") && !Boolean.TRUE.equals(table.getIsHidden()) && table.getPrimaryKeyField() != null
|
||||
&& (table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_GET) || table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_QUERY)))
|
||||
.sorted((a, b) -> a.getName().compareTo(b.getName())).toList();
|
||||
for(RecordFeature feature : RecordFeature.values())
|
||||
{
|
||||
List<String> on = new ArrayList<>();
|
||||
for(QTableMetaData table : hostTables)
|
||||
{
|
||||
if(RecordFeaturesResolver.resolve(instance, table).getFeatures().get(feature).getEnabled())
|
||||
{
|
||||
on.add(table.getLabel() == null ? table.getName() : table.getLabel());
|
||||
}
|
||||
}
|
||||
String source = featuresWithRows.contains(feature.name()) ? "Feature Settings" : "Configuration";
|
||||
String name = friendly(feature);
|
||||
if(on.isEmpty())
|
||||
{
|
||||
items.add(new Item(name, "Off", "No table", source, "Add a Feature Settings row (scope INSTANCE, or TABLE for one table) and turn it on."));
|
||||
}
|
||||
else
|
||||
{
|
||||
boolean all = on.size() == hostTables.size();
|
||||
String where = all ? "Every table" : String.join(", ", on.subList(0, Math.min(NAMED_TABLES, on.size()))) + (on.size() > NAMED_TABLES ? " and " + (on.size() - NAMED_TABLES) + " more" : "");
|
||||
items.add(new Item(name, all ? "On" : "On for " + on.size() + " of " + hostTables.size() + " tables", where, source, null));
|
||||
}
|
||||
}
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** TAGS becomes Tags, CUSTOM_FIELDS becomes Custom fields.
|
||||
*******************************************************************************/
|
||||
static String friendly(RecordFeature feature)
|
||||
{
|
||||
String lower = feature.name().toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
|
||||
}
|
||||
|
||||
}
|
||||
+42
-6
@@ -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);
|
||||
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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.metadata;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-collaboration.yaml (the QQQ help-bundle format) to the
|
||||
** Collaboration app and its collaboration_* tables: what each table is for and
|
||||
** how its rows get created, shown on the table screen and when it is empty.
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned
|
||||
** QQQ release includes it; the bundle file stays the same.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-collaboration.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = CollaborationHelp.class.getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-collaboration".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every collaboration_* table in the instance.
|
||||
** A host's own help on a table is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance instance, CollaborationQBitConfig config) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = instance.getApp(config.getAppName());
|
||||
JsonNode appEntry = entries.get("app:collaboration;slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+6
-15
@@ -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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
bundle: qbit-collaboration
|
||||
version: 1
|
||||
layer: QBIT
|
||||
# Help for the Collaboration app and every collaboration_* table, in the QQQ help-bundle
|
||||
# format (qqq#1198). Each table entry says what the table is for and how its rows get
|
||||
# created, so an empty table explains itself. CollaborationHelp applies it until the core
|
||||
# HelpBundles registry ships in a QQQ release (STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8)).
|
||||
# Summaries stay under 120 characters (CollaborationTablesGuardrailTest).
|
||||
entries:
|
||||
- key: app:collaboration;slot:help
|
||||
summary: "Comments, files, tags, tasks and sharing on any record, and the switches that turn each on."
|
||||
content: |
|
||||
The Setup card on the home lists every record feature, whether it is on, for which tables and where that comes from. Features start from the configuration in code; open Feature Settings and run Create rows from configuration to see those settings as rows you can edit. Everything else here is content that people create while they work.
|
||||
|
||||
- key: table:collaboration_feature_setting;slot:help
|
||||
summary: "Turns a record feature on or off for the whole instance, an app or one table, with its permission mode."
|
||||
content: |
|
||||
**How rows get created:** run the table action Create rows from configuration once. It writes the settings the code configuration already applies (one Instance row per feature, plus an App or Table row wherever the code declares one), so nothing changes until you edit a row. After that an administrator adds or edits rows: pick a scope (Instance, App or Table), a feature, and turn it on. Rows are applied after all code configuration. An Instance row overrides what code declares on any app or table that has no row of its own, so once rows exist, change features in the rows, not in code. Tags also take `{"mode":"FREE"}` or `{"mode":"APPROVED_ONLY"}` in Settings. Rows only fill in when the table is empty, so the action never overwrites an edit.
|
||||
|
||||
- key: table:collaboration_user;slot:help
|
||||
summary: "The people directory used for mentions, sharing and assignment, kept up to date automatically."
|
||||
content: |
|
||||
**How rows get created:** automatically, the first time a signed-in person uses a collaboration feature (and again when their name or avatar changes). It cannot be edited by hand. Empty means nobody has used a collaboration feature yet.
|
||||
|
||||
- key: table:collaboration_favorite;slot:help
|
||||
summary: "Records, tables and apps each person has starred, in the order they chose."
|
||||
content: |
|
||||
**How rows get created:** a person stars a record, table or app (the star in the record header), and the row belongs to them alone. Administrators can browse them. Empty means nobody has starred anything yet, or Favorites is off in Feature Settings.
|
||||
|
||||
- key: table:collaboration_recent;slot:help
|
||||
summary: "The records each person opened lately, newest first, trimmed daily."
|
||||
content: |
|
||||
**How rows get created:** automatically when a person opens a record while Recents is on. The Trim recents job keeps each person's newest rows. Empty means nobody has opened a record yet, or Recents is off in Feature Settings.
|
||||
|
||||
- key: table:collaboration_attachment;slot:help
|
||||
summary: "Files attached to records, with where each file is stored."
|
||||
content: |
|
||||
**How rows get created:** a person uploads a file on a record (the Attachments panel). The file itself goes to object storage; this row describes it. Turn Attachments on for the table in Feature Settings first.
|
||||
|
||||
- key: table:collaboration_comment;slot:help
|
||||
summary: "Comments and replies on records, with who wrote them and whether the thread is resolved."
|
||||
content: |
|
||||
**How rows get created:** a person writes a comment on a record (the Comments panel). Turn Comments on for the table in Feature Settings first. Edits are kept in Comment Revisions.
|
||||
|
||||
- key: table:collaboration_comment_revision;slot:help
|
||||
summary: "Earlier versions of edited comments."
|
||||
content: |
|
||||
**How rows get created:** automatically each time someone edits a comment. Empty means no comment has been edited.
|
||||
|
||||
- key: table:collaboration_mention;slot:help
|
||||
summary: "Each @mention of a person in a comment, and whether they were told and have read it."
|
||||
content: |
|
||||
**How rows get created:** automatically when a comment mentions someone. Empty means no comment has mentioned anyone yet.
|
||||
|
||||
- key: table:collaboration_follow;slot:help
|
||||
summary: "Who follows which record, and how they want to hear about changes."
|
||||
content: |
|
||||
**How rows get created:** a person follows a record (or is made a follower by an action), and chooses how to be notified. Turn Follow on for the table in Feature Settings first.
|
||||
|
||||
- key: table:collaboration_follow_digest;slot:help
|
||||
summary: "Batched change notices waiting to go out to followers."
|
||||
content: |
|
||||
**How rows get created:** automatically when a followed record changes and the follower prefers a digest. The Send follow digests job sends them and clears them. Empty means nothing is waiting.
|
||||
|
||||
- key: table:collaboration_activity;slot:help
|
||||
summary: "The timeline of what happened on each record: comments, files, tags, tasks and sharing."
|
||||
content: |
|
||||
**How rows get created:** automatically by the other collaboration features as people use them. Turn Timeline on for the table in Feature Settings to show it. Empty means nothing has happened on a record yet.
|
||||
|
||||
- key: table:collaboration_tag;slot:help
|
||||
summary: "The tag vocabulary: global tags and tags scoped to one table, approved or waiting for approval."
|
||||
content: |
|
||||
**How rows get created:** a person adds a tag from a record (free mode), or suggests one that an administrator approves (approved-only mode, set in Feature Settings). Administrators can merge or delete tags with the table actions.
|
||||
|
||||
- key: table:collaboration_record_tag;slot:help
|
||||
summary: "Which tags are on which records."
|
||||
content: |
|
||||
**How rows get created:** a person applies a tag on a record, or a bulk Add tags action on a host table does. Turn Tags on for the table in Feature Settings first.
|
||||
|
||||
- key: table:collaboration_field_definition;slot:help
|
||||
summary: "Custom fields an administrator adds to a table without a deployment: name, type and status."
|
||||
content: |
|
||||
**How rows get created:** a field manager (collaboration.fields.manage) adds a definition with New: pick the table, name and type, then use the Publish field action to make it Active. Turn Custom fields on for the table in Feature Settings first. Rebuild index and Archive field are table actions too.
|
||||
|
||||
- key: table:collaboration_field_value;slot:help
|
||||
summary: "The values people entered for custom fields on records."
|
||||
content: |
|
||||
**How rows get created:** a person fills in a custom field on a record. Empty means no custom field has a value yet.
|
||||
|
||||
- key: table:collaboration_share;slot:help
|
||||
summary: "Records shared with a person, with a note, and when they opened it."
|
||||
content: |
|
||||
**How rows get created:** a person shares a record with someone from the record header. Turn Sharing on for the table in Feature Settings first.
|
||||
|
||||
- key: table:collaboration_access_request;slot:help
|
||||
summary: "Requests for access to a record that someone could not open, and how each was answered."
|
||||
content: |
|
||||
**How rows get created:** a person who cannot open a record asks for access. Approvers answer from My work or here; who approves is the table's accessApproverPermission setting (default collaboration.admin). Turn Sharing on for the table first.
|
||||
|
||||
- key: table:collaboration_task;slot:help
|
||||
summary: "Tasks on records with an assignee, a due date and a reminder."
|
||||
content: |
|
||||
**How rows get created:** a person adds a task on a record (the Tasks panel). The Send task reminders job sends the reminders. Turn Tasks on for the table in Feature Settings first.
|
||||
|
||||
- key: table:collaboration_record_assignment;slot:help
|
||||
summary: "Who owns and who is assigned to each record."
|
||||
content: |
|
||||
**How rows get created:** a person assigns a record from its header, or the Assign action on a host table does it in bulk. Turn Assignment on for the table in Feature Settings first.
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* 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.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.collaboration.metadata.CollaborationHelp;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every collaboration_* table:
|
||||
** - runtime code (not just its model, metadata or home screen) reads or writes
|
||||
** it, so no table exists only on paper;
|
||||
** - it is either seeded (feature settings: FeatureSettingsDefaults, from the
|
||||
** configuration at run time) or listed here with why it starts empty;
|
||||
** - it has bundle help that says what it is for and how its rows get created.
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class CollaborationTablesGuardrailTest extends PersonalServiceFixture
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "collaboration");
|
||||
|
||||
/*******************************************************************************
|
||||
** Seeded from the code configuration by "Create rows from configuration".
|
||||
*******************************************************************************/
|
||||
static final List<String> SEEDED = List.of("collaboration_feature_setting");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("collaboration_user", "runtime: filled as signed-in people use collaboration services"),
|
||||
Map.entry("collaboration_favorite", "user content: a person stars something"),
|
||||
Map.entry("collaboration_recent", "runtime: a person opens a record"),
|
||||
Map.entry("collaboration_attachment", "user content: a person uploads a file"),
|
||||
Map.entry("collaboration_comment", "user content: a person comments"),
|
||||
Map.entry("collaboration_comment_revision", "runtime: a comment is edited"),
|
||||
Map.entry("collaboration_mention", "runtime: a comment mentions someone"),
|
||||
Map.entry("collaboration_follow", "user content: a person follows a record"),
|
||||
Map.entry("collaboration_follow_digest", "runtime: a followed record changes"),
|
||||
Map.entry("collaboration_activity", "runtime: collaboration features are used"),
|
||||
Map.entry("collaboration_tag", "user content: a person or administrator adds a tag"),
|
||||
Map.entry("collaboration_record_tag", "user content: a person tags a record"),
|
||||
Map.entry("collaboration_field_definition", "administrator content: a field manager defines a custom field"),
|
||||
Map.entry("collaboration_field_value", "user content: a person fills in a custom field"),
|
||||
Map.entry("collaboration_share", "user content: a person shares a record"),
|
||||
Map.entry("collaboration_access_request", "user content: a person asks for access"),
|
||||
Map.entry("collaboration_task", "user content: a person adds a task"),
|
||||
Map.entry("collaboration_record_assignment", "user content: a person assigns a record"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
Map<String, String> constantByTable = constantByTable();
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app"))
|
||||
&& !p.getFileName().toString().equals("CoreCollaboration.java") && !p.getFileName().toString().equals("CollaborationSchema.java"))
|
||||
.map(CollaborationTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : collaborationTables())
|
||||
{
|
||||
String constant = constantByTable.get(table);
|
||||
assertThat(constant).as("constant for " + table).isNotNull();
|
||||
Pattern use = Pattern.compile("CollaborationTables\\." + constant + "\\b");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
|
||||
.as(table + " (" + constant + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
for(String table : collaborationTables())
|
||||
{
|
||||
boolean seeded = SEEDED.contains(table);
|
||||
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
|
||||
}
|
||||
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> instance.getTable(table) != null, "STARTS_EMPTY names only real tables");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// the seeded table has a real action that writes its rows, and it is named in help //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getTableName()).isEqualTo("collaboration_feature_setting");
|
||||
assertThat(FeatureSettingsDefaults.configuredRows(instance, config)).isNotEmpty();
|
||||
assertThat(help("collaboration_feature_setting").getFirst().getContent()).contains(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getLabel());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
assertThat(CollaborationHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
for(String table : collaborationTables())
|
||||
{
|
||||
List<QHelpContent> help = help(table);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.getFirst().getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
}
|
||||
assertThat(instance.getApp(config.getAppName()).getHelpContent()).containsKey(CollaborationHelp.SLOT);
|
||||
assertThat(CollaborationHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null,
|
||||
"help names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host's own help on a table is kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostHelpIsNotOverridden() throws Exception
|
||||
{
|
||||
instance.getTable("collaboration_tag").getHelpContent().put(CollaborationHelp.SLOT, List.of(new QHelpContent().withContentAsMarkdown("host text")));
|
||||
CollaborationHelp.apply(instance, config);
|
||||
assertThat(help("collaboration_tag").getFirst().getContent()).isEqualTo("host text");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QHelpContent> help(String table)
|
||||
{
|
||||
return (instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(CollaborationHelp.SLOT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> collaborationTables()
|
||||
{
|
||||
List<String> tables = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("collaboration_")).sorted().toList();
|
||||
assertThat(tables).hasSize(19);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CollaborationTables declares X = "collaboration_x".
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> constantByTable() throws Exception
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
Matcher m = Pattern.compile("String\\s+([A-Z_]+)\\s*=\\s*\"(collaboration_[a-z_]+)\"").matcher(read(MAIN.resolve("model").resolve("CollaborationTables.java")));
|
||||
while(m.find())
|
||||
{
|
||||
rs.put(m.group(2), m.group(1));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,398 @@
|
||||
/*
|
||||
* 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.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationSetupStatus;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
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.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FeaturePermissionMode;
|
||||
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.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Create rows from configuration" defaults for collaboration_feature_setting.
|
||||
** Main claim: writing the rows changes nothing. For several configurations
|
||||
** (default, table features, app and table declarations, permission modes,
|
||||
** settings, two apps containing one table), every table resolves to exactly the
|
||||
** same enabled flag, permission mode and settings with and without the rows.
|
||||
*******************************************************************************/
|
||||
class FeatureSettingsDefaultsTest
|
||||
{
|
||||
private QInstance instance;
|
||||
private CollaborationQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh instance: a backend, three host tables, two apps, then the qbit.
|
||||
** `code` runs after the qbit is produced and declares features on apps/tables.
|
||||
*******************************************************************************/
|
||||
private void build(Consumer<CollaborationQBitConfig> configure, Consumer<QInstance> code) throws Exception
|
||||
{
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
for(String name : List.of("document", "invoice", "receipt"))
|
||||
{
|
||||
instance.addTable(new QTableMetaData().withName(name).withLabel(name.substring(0, 1).toUpperCase() + name.substring(1) + "s").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("title", QFieldType.STRING)).withRecordLabelFormatAndFields("%s", "title"));
|
||||
}
|
||||
instance.addApp(new QAppMetaData().withName("sales").withLabel("Sales").withIcon(new QIcon("sell")).withSection(new QAppSection().withName("s").withLabel("S").withTables(List.of("invoice", "receipt"))));
|
||||
instance.addApp(new QAppMetaData().withName("ops").withLabel("Ops").withIcon(new QIcon("build")).withSection(new QAppSection().withName("o").withLabel("O").withTables(List.of("receipt", "document"))));
|
||||
config = new CollaborationQBitConfig().withBackendName("memory");
|
||||
configure.accept(config);
|
||||
new CollaborationQBitProducer().withQBitConfig(config).produce(instance);
|
||||
code.accept(instance);
|
||||
new com.kingsrook.qqq.backend.core.instances.QInstanceEnricher(instance).enrich();
|
||||
admin();
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void admin()
|
||||
{
|
||||
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("admin")).withPermissions(CollaborationPermissions.ADMIN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.close(instance);
|
||||
com.kingsrook.qqq.backend.core.realtime.RealtimeBus.close(instance);
|
||||
QContext.clear();
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What every host table resolves to for every feature: enabled, permission mode,
|
||||
** settings (with the TAGS mode as TagsService reads it).
|
||||
*******************************************************************************/
|
||||
private Map<String, String> effective()
|
||||
{
|
||||
Map<String, String> rs = new TreeMap<>();
|
||||
for(String table : List.of("document", "invoice", "receipt"))
|
||||
{
|
||||
var resolved = RecordFeaturesResolver.resolve(instance, instance.getTable(table));
|
||||
for(RecordFeature feature : RecordFeature.values())
|
||||
{
|
||||
var one = resolved.getFeatures().get(feature);
|
||||
Map<String, java.io.Serializable> settings = new TreeMap<>(one.getSettings());
|
||||
if(feature == RecordFeature.TAGS)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// TagsService reads a missing mode as the configured default tag mode, //
|
||||
// so a row that spells the default out is the same effective setting. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
settings.putIfAbsent("mode", config.getDefaultTagMode());
|
||||
}
|
||||
rs.put(table + "." + feature, one.getEnabled() + " " + one.getPermissionMode() + " " + settings);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the real "Create rows from configuration" process end to end.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ProcessSummaryLine> create() throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(FeatureSettingsDefaultsProcess.NAME);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
return ((List<ProcessSummaryLine>) output.getValues().get(com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows() throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(CollaborationTables.FEATURE_SETTING).withFilter(new QQueryFilter())).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configurations: defaults; table features with GRANTED_ONLY and a table
|
||||
** TAGS mode; an app that turns attachments on and sets the sharing approver;
|
||||
** a different default tag mode and default features; two apps with one table.
|
||||
*******************************************************************************/
|
||||
private void scenario(int number) throws Exception
|
||||
{
|
||||
switch(number)
|
||||
{
|
||||
case 0 -> build(c -> { }, i -> { });
|
||||
case 1 -> build(c -> c.withDefaultFeatures(RecordFeature.COMMENTS, RecordFeature.TAGS).withTableFeatures("document", RecordFeature.TAGS, RecordFeature.TASKS), i ->
|
||||
{
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getTable("invoice")).withPermissionMode(RecordFeature.SHARING, FeaturePermissionMode.GRANTED_ONLY)
|
||||
.withSetting(RecordFeature.TAGS, "mode", "APPROVED_ONLY");
|
||||
});
|
||||
case 2 -> build(c -> c.withDefaultTagMode("APPROVED_ONLY"), i ->
|
||||
{
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getApp("sales")).withEnabled(RecordFeature.ATTACHMENTS, true).withSetting(RecordFeature.SHARING, "accessApproverPermission", "sales.admin");
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getTable("receipt")).withEnabled(RecordFeature.COMMENTS, true).withEnabled(RecordFeature.FAVORITES, false);
|
||||
});
|
||||
case 3 -> build(c -> c.withDefaultFeatures(), i ->
|
||||
{
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getApp("sales")).withEnabled(RecordFeature.TASKS, true).withPermissionMode(RecordFeature.TASKS, FeaturePermissionMode.GRANTED_ONLY);
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getApp("ops")).withEnabled(RecordFeature.TASKS, false);
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getApp("ops")).withSetting(RecordFeature.TAGS, "mode", "APPROVED_ONLY");
|
||||
});
|
||||
default -> throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The proof: for every scenario the effective feature set is identical with
|
||||
** and without the seeded rows, and rows really were created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seededRowsChangeNothing() throws Exception
|
||||
{
|
||||
for(int number = 0; number <= 3; number++)
|
||||
{
|
||||
scenario(number);
|
||||
Map<String, String> without = effective();
|
||||
assertThat(rows()).as("scenario " + number + " starts empty").isEmpty();
|
||||
|
||||
List<ProcessSummaryLine> summary = create();
|
||||
assertThat(summary).as("scenario " + number).isNotEmpty();
|
||||
List<QRecord> rows = rows();
|
||||
assertThat(rows).as("scenario " + number + " rows").hasSizeGreaterThanOrEqualTo(RecordFeature.values().length);
|
||||
assertThat(rows.stream().filter(row -> "INSTANCE".equals(row.getValueString("scopeType")))).hasSize(RecordFeature.values().length);
|
||||
|
||||
assertThat(effective()).as("scenario " + number + ": effective settings with the seeded rows").containsExactlyInAnyOrderEntriesOf(without);
|
||||
tearDown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The default scenario in plain terms: favorites, recents and timeline are
|
||||
** the only features on, TAGS carries the mode, everything else is off.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void defaultRowsMirrorTheConfiguredDefaults() throws Exception
|
||||
{
|
||||
scenario(0);
|
||||
create();
|
||||
for(QRecord row : rows())
|
||||
{
|
||||
assertThat(row.getValueString("scopeType")).isEqualTo("INSTANCE");
|
||||
assertThat(row.getValueString("scopeName")).isEqualTo("instance");
|
||||
RecordFeature feature = RecordFeature.valueOf(row.getValueString("feature"));
|
||||
boolean on = List.of(RecordFeature.FAVORITES, RecordFeature.RECENTS, RecordFeature.TIMELINE).contains(feature);
|
||||
assertThat(row.getValueBoolean("enabled")).as(feature.name()).isEqualTo(on);
|
||||
assertThat(row.getValueString("permissionMode")).isEqualTo("EDIT_OR_GRANTED");
|
||||
assertThat(row.getValueString("settings")).isEqualTo(feature == RecordFeature.TAGS ? "{\"mode\":\"FREE\"}" : null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Running it again creates nothing; an edit survives; and the rows are live:
|
||||
** turning COMMENTS on in its row turns comments on for every host table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void insertOnlyAndTheRowsAreLive() throws Exception
|
||||
{
|
||||
scenario(0);
|
||||
create();
|
||||
int count = rows().size();
|
||||
assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("document")).isEnabled(RecordFeature.COMMENTS)).isFalse();
|
||||
|
||||
QRecord comments = rows().stream().filter(row -> "COMMENTS".equals(row.getValueString("feature"))).findFirst().orElseThrow();
|
||||
new UpdateAction().execute(new UpdateInput(CollaborationTables.FEATURE_SETTING).withRecords(List.of(new QRecord().withValue("id", comments.getValue("id")).withValue("enabled", true))));
|
||||
assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("document")).isEnabled(RecordFeature.COMMENTS)).as("the edited row drives the runtime").isTrue();
|
||||
assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("invoice")).isEnabled(RecordFeature.COMMENTS)).isTrue();
|
||||
|
||||
List<ProcessSummaryLine> second = create();
|
||||
assertThat(second).singleElement().satisfies(line -> assertThat(line.getMessage()).contains("already has rows"));
|
||||
assertThat(rows()).hasSize(count);
|
||||
assertThat(rows().stream().filter(row -> "COMMENTS".equals(row.getValueString("feature"))).findFirst().orElseThrow().getValueBoolean("enabled")).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table that already has an administrator's rows gets nothing added.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void neverAddsToExistingRows() throws Exception
|
||||
{
|
||||
scenario(1);
|
||||
CollaborationRecords.insert(CollaborationTables.FEATURE_SETTING, new QRecord().withValue("scopeType", "INSTANCE").withValue("scopeName", "instance")
|
||||
.withValue("feature", "TASKS").withValue("enabled", true));
|
||||
assertThat(FeatureSettingsDefaults.missingRows(instance, config)).isEmpty();
|
||||
create();
|
||||
assertThat(rows()).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only collaboration administrators may run it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void adminOnly() throws Exception
|
||||
{
|
||||
scenario(0);
|
||||
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("viewer")).withPermissions(CollaborationPermissions.VIEW));
|
||||
assertThatThrownBy(() -> new FeatureSettingsDefaultsProcess.AdminOnly().checkPermissionsThrowing(null, null)).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getMinInputRecords()).isEqualTo(0);
|
||||
assertThat(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getTableName()).isEqualTo(CollaborationTables.FEATURE_SETTING);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card reports the configuration, then the rows, and what is on where.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void setupCardUsesTheRuntimeResolution() throws Exception
|
||||
{
|
||||
scenario(1);
|
||||
List<CollaborationSetupStatus.Item> before = CollaborationSetupStatus.load(instance);
|
||||
assertThat(before).hasSize(RecordFeature.values().length + 1);
|
||||
assertThat(before.getFirst().state()).isEqualTo("Using code defaults");
|
||||
assertThat(before.getFirst().nextStep()).contains("Create rows from configuration");
|
||||
assertThat(item(before, "Comments").state()).isEqualTo("On for 2 of 3 tables");
|
||||
assertThat(item(before, "Comments").where()).isEqualTo("Invoices, Receipts");
|
||||
assertThat(item(before, "Favorites").state()).isEqualTo("Off");
|
||||
assertThat(item(before, "Tasks").state()).isEqualTo("On for 1 of 3 tables");
|
||||
assertThat(item(before, "Tasks").where()).isEqualTo("Documents");
|
||||
assertThat(item(before, "Presence").state()).isEqualTo("Off");
|
||||
assertThat(item(before, "Presence").nextStep()).contains("Feature Settings");
|
||||
assertThat(item(before, "Tasks").source()).isEqualTo("Configuration");
|
||||
|
||||
create();
|
||||
List<CollaborationSetupStatus.Item> after = CollaborationSetupStatus.load(instance);
|
||||
assertThat(after.getFirst().state()).isEqualTo("Set up");
|
||||
assertThat(item(after, "Tasks").source()).isEqualTo("Feature Settings");
|
||||
List<String> beforeStates = new ArrayList<>(before.stream().skip(1).map(CollaborationSetupStatus.Item::state).toList());
|
||||
assertThat(after.stream().skip(1).map(CollaborationSetupStatus.Item::state).toList()).as("the card reports the same thing before and after").isEqualTo(beforeStates);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The load is one transaction: while it is open (and after it fails and is
|
||||
** rolled back) the override overlay never sees any of its rows, so a failure
|
||||
** on a later page cannot leave Instance rows without the rows that restate
|
||||
** the code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void laterPageFailureNeverReachesTheOverlay() throws Exception
|
||||
{
|
||||
scenario(1);
|
||||
long generation = RuntimeMetaDataOverlay.getGeneration(instance);
|
||||
FeatureSettingsDefaultsProcess.Load load = new FeatureSettingsDefaultsProcess.Load();
|
||||
assertThat(load.openTransaction(new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput())).isPresent();
|
||||
com.kingsrook.qqq.backend.core.actions.QBackendTransaction tx = new com.kingsrook.qqq.backend.core.actions.QBackendTransaction();
|
||||
load.setTransaction(java.util.Optional.of(tx));
|
||||
|
||||
List<QRecord> all = FeatureSettingsDefaults.missingRows(instance, config);
|
||||
var page1 = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput();
|
||||
page1.setRecords(new ArrayList<>(all.subList(0, 3)));
|
||||
load.runOnePage(page1, new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput());
|
||||
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).as("no refresh before commit").isEqualTo(generation);
|
||||
|
||||
var page2 = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput();
|
||||
page2.setRecords(new ArrayList<>(List.of(new QRecord().withValue("scopeType", "INSTANCE"))));
|
||||
assertThatThrownBy(() -> load.runOnePage(page2, new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput()))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QException.class);
|
||||
tx.rollback();
|
||||
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).as("rolled back: the overlay never changed").isEqualTo(generation);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static CollaborationSetupStatus.Item item(List<CollaborationSetupStatus.Item> items, String feature)
|
||||
{
|
||||
return (items.stream().filter(item -> feature.equals(item.feature())).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
+14
@@ -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
|
||||
{
|
||||
|
||||
+24
-1
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user