Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95506ed706
|
||
|
|
7b7c921079
|
No files matched your search
@@ -16,6 +16,74 @@ Comment parents list the selected thread's root and replies. Feature-setting sco
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
to `127.0.0.1`; stopping the JVM discards edits and uploaded attachments.
|
||||
|
||||
Build the Next static export in `qqq-frontend-next`:
|
||||
|
||||
```bash
|
||||
QQQ_NEXT_OUTPUT=export pnpm build
|
||||
```
|
||||
|
||||
In PowerShell, use `$env:QQQ_NEXT_OUTPUT = 'export'; pnpm build` for that command.
|
||||
Then, from this repository, prepare the test classpath and place the export under
|
||||
a classpath directory named `next-dashboard`, following StorageDevServer's layout:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd -o -q test-compile dependency:build-classpath '-Dmdep.includeScope=test' '-Dmdep.outputFile=target/demo-classpath.txt'
|
||||
New-Item -ItemType Directory -Force target/demo-frontend/next-dashboard | Out-Null
|
||||
Copy-Item -Path ../qqq-frontend-next/out/* -Destination target/demo-frontend/next-dashboard -Recurse -Force
|
||||
$demoDeps = (Get-Content target/demo-classpath.txt -Raw).Trim()
|
||||
$demoClasspath = "target/test-classes;target/classes;$demoDeps;target/demo-frontend"
|
||||
java '-Dqqq.javalin.frontend=next' '-Dcollab.dev.port=8098' '-Dcollab.dev.user=alice' -cp $demoClasspath com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer
|
||||
```
|
||||
|
||||
The equivalent Java command is
|
||||
`java -Dqqq.javalin.frontend=next -Dcollab.dev.port=8098 -Dcollab.dev.user=alice -cp "target/test-classes;target/classes;DEPS;EXPORT" com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer`.
|
||||
`DEPS` is the generated classpath file's contents; `EXPORT` is the folder **containing**
|
||||
`next-dashboard/`, not the export itself. On macOS/Linux use `./mvnw` and `:` instead
|
||||
of `;` between classpath entries.
|
||||
|
||||
Open [the demo](http://localhost:8098/?createMockSession=true), then **Sales → People**
|
||||
or **Sales orders**. [Person 1](http://localhost:8098/app/person/1) and
|
||||
[order 1](http://localhost:8098/app/sales_order/1) have the richest collaboration panels.
|
||||
Select a user with `-Dcollab.dev.user=alice|bob|admin` (one value, not the literal list)
|
||||
and restart the server to switch. The MOCK session customizer supplies stable identities
|
||||
and real permissions, also used by recipient access checks:
|
||||
|
||||
| User | Permissions |
|
||||
| --- | --- |
|
||||
| `alice` — Alice Morgan (default) | Full Sales access and comment, attach, tag, share, task and assign permissions |
|
||||
| `bob` — Bob Chen | People read/comment; no Sales order access |
|
||||
| `admin` — Demo Administrator | Full Sales access plus `collaboration.admin`, vocabulary/field management and Admin → Collaboration |
|
||||
|
||||
Both tables enable every record feature. People use FREE tags and host owner/assignee
|
||||
fields; orders use APPROVED_ONLY tags. The seed contains 25 fictional contacts at
|
||||
10 companies, 40 orders with deterministic amounts/dates, eight approved tags,
|
||||
two ACTIVE custom fields (Preferred contact; searchable Purchase order no.), six
|
||||
comments with three mentions and a resolved thread, three generated PNG/PDF attachments,
|
||||
follows, four tasks (one overdue and one completed), two order assignments, two shares, Bob's open order-access
|
||||
request, and six favorites/recents for Alice. Ordinary record updates create FIELD
|
||||
audit history. Task deadlines are relative to startup so the overdue example stays useful.
|
||||
|
||||
Attachment previews/downloads use real in-memory bytes and expiring local URLs.
|
||||
Notifications are recorded in memory; they do not send email. Presence is live and
|
||||
ephemeral: the current browser supplies its heartbeat; there are no fake persistent viewers.
|
||||
Two current permission edge cases remain visible in this snapshot: Bob can start a
|
||||
comment thread, but replying to another author's thread fails its activity update;
|
||||
an access request carrying a share ID for an unreadable parent returns 404. The seed
|
||||
uses Bob's own thread and a direct access request without a share ID.
|
||||
The test builds and seeds without frontend assets, validates the instance, then checks
|
||||
the version/collaboration endpoints, downloads and Bob's restricted access on a free port:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd -o -q verify
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Add the dependency (the snapshot must first be published by CI, or installed locally):
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
|
||||
case "SELECT", "MULTI_SELECT" ->
|
||||
{
|
||||
String source = "collaboration_field_options_" + definition.getValueInteger("id");
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName(source).withType(QPossibleValueSourceType.ENUM)
|
||||
instance.getPossibleValueSources().put(source, new QPossibleValueSource().withName(source).withType(QPossibleValueSourceType.ENUM)
|
||||
.withEnumValues(new ArrayList<>(FieldDefinitions.options(definition).values())));
|
||||
field.withPossibleValueSourceName(source);
|
||||
if("MULTI_SELECT".equals(definition.getValueString("type")))
|
||||
|
||||
@@ -57,6 +57,30 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
*******************************************************************************/
|
||||
public class CustomFieldsTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Refreshing a SELECT must replace its generated vocabulary and allow later fields.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void publishedSelectSurvivesRefreshAndOptionChanges() throws Exception
|
||||
{
|
||||
admin();
|
||||
QRecord choice = draft("contact", "SELECT");
|
||||
new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
|
||||
new QRecord().withValue("id", choice.getValue("id")).withValue("options", "[{\"id\":\"email\",\"label\":\"Email\"}]"))));
|
||||
FieldDefinitions.apply(choice.getValue("id"), "Publish");
|
||||
QRecord reference = draft("reference", "STRING");
|
||||
FieldDefinitions.apply(reference.getValue("id"), "Publish");
|
||||
assertThat(instance.getTable("document").getFields()).containsKeys("cf_contact", "cf_reference");
|
||||
new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
|
||||
new QRecord().withValue("id", choice.getValue("id")).withValue("options", "[{\"id\":\"email\",\"label\":\"Email\"},{\"id\":\"phone\",\"label\":\"Phone\"}]"))));
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
String source = instance.getTable("document").getField("cf_contact").getPossibleValueSourceName();
|
||||
assertThat(instance.getPossibleValueSource(source).getEnumValues()).hasSize(2);
|
||||
assertThat(instance.getTable("document").getFields()).containsKeys("cf_contact", "cf_reference");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a real draft through the permission and pre-insert customizers.
|
||||
*******************************************************************************/
|
||||
|
||||
+681
@@ -0,0 +1,681 @@
|
||||
/*
|
||||
* 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.devserver;
|
||||
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import javax.imageio.ImageIO;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitProducer;
|
||||
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentsService;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentsService;
|
||||
import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.notify.RecordingNotifier;
|
||||
import com.kingsrook.qbits.collaboration.people.PeopleService;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qbits.collaboration.recents.RecentsService;
|
||||
import com.kingsrook.qbits.collaboration.sharing.AccessRequestService;
|
||||
import com.kingsrook.qbits.collaboration.sharing.PermissionHolderDirectory;
|
||||
import com.kingsrook.qbits.collaboration.sharing.SharingService;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagsService;
|
||||
import com.kingsrook.qbits.collaboration.tasks.TasksService;
|
||||
import com.kingsrook.qbits.objectstorage.api.PresignGet;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
|
||||
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.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
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.RecordOwnership;
|
||||
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.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
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.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Disposable Sales CRM for viewing collaboration in the Next dashboard.
|
||||
** Build the Next export with QQQ_NEXT_OUTPUT=export pnpm build, then place out/
|
||||
** under EXPORT/next-dashboard/ (EXPORT, not next-dashboard, is on the classpath).
|
||||
** After mvnw.cmd test-compile dependency:build-classpath -Dmdep.includeScope=test
|
||||
** -Dmdep.outputFile=target/demo-classpath.txt, run:
|
||||
** java -Dqqq.javalin.frontend=next -Dcollab.dev.user=alice -Dcollab.dev.port=8098
|
||||
** -cp "target/test-classes;target/classes;DEPS;EXPORT" com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer
|
||||
** DEPS is the content of target/demo-classpath.txt. See README for PowerShell.
|
||||
*******************************************************************************/
|
||||
public class CollaborationDevServer extends AbstractQQQApplication
|
||||
{
|
||||
private final QInstance instance;
|
||||
static final List<String> USERS = List.of("alice", "bob", "admin");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep the already seeded instance when Javalin initializes the application.
|
||||
*******************************************************************************/
|
||||
public CollaborationDevServer(QInstance instance)
|
||||
{
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No HTTP server is needed to build or test the demo.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance()
|
||||
{
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Select a demo identity for this JVM; restarting resets all demo data.
|
||||
*******************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
String user = System.getProperty("collab.dev.user", "alice");
|
||||
session(user);
|
||||
int port = Integer.parseInt(System.getProperty("collab.dev.port", "8098"));
|
||||
QInstance instance = buildInstance();
|
||||
seed(instance);
|
||||
QApplicationJavalinServer server = server(instance, port, true);
|
||||
server.start();
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() ->
|
||||
{
|
||||
server.stop();
|
||||
PresenceRegistry.close(instance);
|
||||
RealtimeBus.close(instance);
|
||||
}));
|
||||
System.out.println("COLLABORATION DEV SERVER READY http://localhost:" + port + "/?createMockSession=true");
|
||||
System.out.println("Person: http://localhost:" + port + "/app/person/1 Order: http://localhost:" + port + "/app/sales_order/1");
|
||||
System.out.println("Users (-Dcollab.dev.user): alice (Sales), bob (people read/comment), admin (all + collaboration.admin). Current: " + user);
|
||||
Thread.currentThread().join();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Share the actual startup configuration with the HTTP smoke test.
|
||||
*******************************************************************************/
|
||||
static QApplicationJavalinServer server(QInstance instance, int port, boolean frontend)
|
||||
{
|
||||
return (new QApplicationJavalinServer(new CollaborationDevServer(instance)).withPort(port)
|
||||
.withServeFrontendNext(frontend).withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
|
||||
.withJavalinConfigCustomizer(config ->
|
||||
{
|
||||
config.jetty.host = "127.0.0.1";
|
||||
MemoryStorage.routes(config);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normal host metadata, plus the same collaboration installation as a real app.
|
||||
*******************************************************************************/
|
||||
static QInstance buildInstance() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData()
|
||||
.withName("mock").withType(QAuthenticationType.MOCK).withCustomizer(new QCodeReference(DemoSessions.class)));
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName("sales_user").withType(QPossibleValueSourceType.ENUM)
|
||||
.withEnumValues(List.of(new QPossibleValue<>("alice", "Alice Morgan"), new QPossibleValue<>("bob", "Bob Chen"), new QPossibleValue<>("admin", "Demo Administrator"))));
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName("sales_order_status").withType(QPossibleValueSourceType.ENUM)
|
||||
.withEnumValues(List.of(new QPossibleValue<>("Draft"), new QPossibleValue<>("Confirmed"), new QPossibleValue<>("Processing"), new QPossibleValue<>("Shipped"), new QPossibleValue<>("Delivered"))));
|
||||
instance.addTable(baseTable("person", "People", "person")
|
||||
.withRecordLabelFormatAndFields("%s %s", "firstName", "lastName")
|
||||
.withField(new QFieldMetaData("firstName", QFieldType.STRING).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("lastName", QFieldType.STRING).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("email", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("company", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("title", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("region", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("ownerUserId", QFieldType.STRING).withLabel("Account owner").withPossibleValueSourceName("sales_user"))
|
||||
.withField(new QFieldMetaData("assigneeUserId", QFieldType.STRING).withLabel("Account partner").withPossibleValueSourceName("sales_user"))
|
||||
.withSection(new QFieldSection("contact", new QIcon("person"), Tier.T1, List.of("id", "firstName", "lastName", "email")).withLabel("Contact"))
|
||||
.withSection(new QFieldSection("account", new QIcon("business"), Tier.T2, List.of("company", "title", "region", "ownerUserId", "assigneeUserId")).withLabel("Account")));
|
||||
instance.addPossibleValueSource(QPossibleValueSource.newForTable("person"));
|
||||
instance.addTable(baseTable("sales_order", "Sales orders", "receipt_long")
|
||||
.withRecordLabelFormatAndFields("%s", "orderNo")
|
||||
.withField(new QFieldMetaData("orderNo", QFieldType.STRING).withLabel("Order no.").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("customer", QFieldType.INTEGER).withPossibleValueSourceName("person").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("status", QFieldType.STRING).withPossibleValueSourceName("sales_order_status"))
|
||||
.withField(new QFieldMetaData("total", QFieldType.DECIMAL))
|
||||
.withField(new QFieldMetaData("orderDate", QFieldType.DATE))
|
||||
.withSection(new QFieldSection("order", new QIcon("receipt_long"), Tier.T1, List.of("id", "orderNo", "customer", "status")).withLabel("Order"))
|
||||
.withSection(new QFieldSection("commercial", new QIcon("payments"), Tier.T2, List.of("total", "orderDate")).withLabel("Commercial details")));
|
||||
instance.addApp(new QAppMetaData().withName("sales").withLabel("Sales").withIcon(new QIcon("storefront"))
|
||||
.withSection(new QAppSection().withName("customers").withLabel("Customers & orders").withTables(List.of("person", "sales_order"))));
|
||||
instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin").withIcon(new QIcon("settings"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("admin")));
|
||||
new AuditsMetaDataProvider().withRecordIdType(QFieldType.STRING).withDefineRecordScreens(true)
|
||||
.defineAll(instance, "memory", table -> table.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS)));
|
||||
new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory")
|
||||
.withAdminApp(true).withParentAppName("admin").withUiApi(true)
|
||||
.withTableFeatures("person", RecordFeature.values()).withTableFeatures("sales_order", RecordFeature.values())
|
||||
.withAttachmentStorage(new QCodeReference(MemoryStorage.class))
|
||||
.withUserSessionProvider(new QCodeReference(DemoSessions.class))
|
||||
.withPermissionHolderDirectory(new QCodeReference(DemoSessions.class))
|
||||
.withNotifier(new QCodeReference(RecordingNotifier.class))
|
||||
.withAssignActionTables(List.of("sales_order"))
|
||||
.withFollowActionTables(List.of("person", "sales_order")).withFollowChangeTables(List.of("person", "sales_order"))
|
||||
.withTagActionTables(List.of("person", "sales_order"))).produce(instance);
|
||||
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("person")).withSetting(RecordFeature.TAGS, "mode", "FREE")
|
||||
.withOwnership(new RecordOwnership().withOwnerFieldName("ownerUserId").withAssigneeFieldName("assigneeUserId"));
|
||||
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("sales_order")).withSetting(RecordFeature.TAGS, "mode", "APPROVED_ONLY");
|
||||
QContext.init(instance, session("admin"));
|
||||
new QInstanceEnricher(instance).enrich();
|
||||
new QInstanceValidator().validate(instance);
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All Sales writes are permission checked and produce field-level audits.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData baseTable(String name, String label, String icon)
|
||||
{
|
||||
return (new QTableMetaData().withName(name).withLabel(label).withIcon(new QIcon(icon)).withBackendName("memory")
|
||||
.withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable grants shared by MOCK browser sessions, mentions and share checks.
|
||||
*******************************************************************************/
|
||||
static QSession session(String user)
|
||||
{
|
||||
if(!USERS.contains(user))
|
||||
{
|
||||
throw (new IllegalArgumentException("collab.dev.user must be alice, bob or admin: " + user));
|
||||
}
|
||||
String name = switch(user)
|
||||
{
|
||||
case "alice" -> "Alice Morgan";
|
||||
case "bob" -> "Bob Chen";
|
||||
default -> "Demo Administrator";
|
||||
};
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference(user).withFullName(name))
|
||||
.withPermissions("person.read", "person.comment");
|
||||
if(!user.equals("bob"))
|
||||
{
|
||||
session.withPermissions("audit.read", "auditDetail.read", "auditTable.read", "auditUser.read");
|
||||
for(String table : List.of("person", "sales_order"))
|
||||
{
|
||||
for(String permission : List.of("read", "write", "insert", "edit", "delete", "comment", "attach", "tag", "share", "task", "assign"))
|
||||
{
|
||||
session.withPermissions(table + "." + permission);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(user.equals("admin"))
|
||||
{
|
||||
session.withPermissions("admin.access", "collaboration.admin", "collaboration.view", "collaboration.tags.manage", "collaboration.fields.manage");
|
||||
}
|
||||
return (session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MOCK normally creates User:number / John Smith; replace both explicitly.
|
||||
*******************************************************************************/
|
||||
public static class DemoSessions implements QAuthenticationModuleCustomizerInterface, UserSessionProviderInterface, PermissionHolderDirectory
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve the browser session UUID while applying the selected user's grants.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession target, Map<String, Object> context)
|
||||
{
|
||||
QSession selected = session(System.getProperty("collab.dev.user", "alice"));
|
||||
target.setUser(selected.getUser());
|
||||
target.setPermissions(selected.getPermissions());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return fresh sessions, so access checks cannot mutate another caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<QSession> sessionForUser(String userId)
|
||||
{
|
||||
return (USERS.contains(userId) ? Optional.of(session(userId)) : Optional.empty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access requests can find their configured approver without external IAM.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Set<String> userIdsWithPermission(String permission)
|
||||
{
|
||||
return (USERS.stream().filter(user -> session(user).getPermissions().contains(permission)).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed through normal actions/services, including lifecycle, access and audits.
|
||||
*******************************************************************************/
|
||||
static void seed(QInstance instance) throws Exception
|
||||
{
|
||||
for(String user : USERS)
|
||||
{
|
||||
QContext.init(instance, session(user));
|
||||
new PeopleService().touch();
|
||||
}
|
||||
QContext.setQSession(session("admin"));
|
||||
publishField(new QRecord().withValue("tableName", "person").withValue("name", "preferred_contact").withValue("label", "Preferred contact")
|
||||
.withValue("type", "SELECT").withValue("options", "[{\"id\":\"email\",\"label\":\"Email\"},{\"id\":\"phone\",\"label\":\"Phone\"},{\"id\":\"video\",\"label\":\"Video call\"}]"));
|
||||
publishField(new QRecord().withValue("tableName", "sales_order").withValue("name", "purchase_order_no").withValue("label", "Purchase order no.")
|
||||
.withValue("type", "STRING").withValue("searchable", true));
|
||||
seedSales();
|
||||
seedCollaboration();
|
||||
QContext.setQSession(session(System.getProperty("collab.dev.user", "alice")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A published definition contributes actual memory JsonPathStorage fields.
|
||||
*******************************************************************************/
|
||||
private static void publishField(QRecord definition) throws Exception
|
||||
{
|
||||
QRecord saved = insert(CollaborationTables.FIELD_DEFINITION, List.of(definition)).getFirst();
|
||||
FieldDefinitions.apply(saved.getValue("id"), "Publish");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fictional but realistic accounts; stable IDs and random seed aid screenshots.
|
||||
*******************************************************************************/
|
||||
private static void seedSales() throws Exception
|
||||
{
|
||||
String[] names = { "Maya Patel", "James Rivera", "Olivia Brooks", "Noah Kim", "Emma Wilson", "Liam Foster", "Ava Mitchell", "Ethan Reed", "Sofia Garcia", "Lucas Bennett",
|
||||
"Isabella Turner", "Mason Wright", "Amelia Scott", "Logan Hayes", "Charlotte Evans", "Oliver Chen", "Harper Davis", "Elijah Morgan", "Evelyn Price", "Henry Lewis",
|
||||
"Abigail Ross", "Jack Sullivan", "Grace Nguyen", "Daniel Park", "Zoe Campbell" };
|
||||
String[] companies = { "Northstar Outfitters", "Prairie Health Supply", "Harbor Coffee Roasters", "Summit Industrial", "Cedar Grove Foods", "Bluebird Logistics", "Riverbend Packaging", "Atlas Workshop", "Orchard Market", "Clearwater Labs" };
|
||||
String[] titles = { "Purchasing Director", "Operations Manager", "Founder", "Supply Chain Lead", "Finance Director" };
|
||||
String[] regions = { "Midwest", "Northeast", "West", "Southeast", "Southwest" };
|
||||
List<QRecord> people = new ArrayList<>();
|
||||
for(int i = 0; i < names.length; i++)
|
||||
{
|
||||
String[] name = names[i].split(" ");
|
||||
people.add(new QRecord().withValue("id", i + 1).withValue("firstName", name[0]).withValue("lastName", name[1])
|
||||
.withValue("email", (name[0] + "." + name[1]).toLowerCase(java.util.Locale.ROOT) + "@example.com")
|
||||
.withValue("company", companies[i % companies.length]).withValue("title", titles[i % titles.length]).withValue("region", regions[i % regions.length])
|
||||
.withValue("ownerUserId", "alice").withValue("assigneeUserId", i % 3 == 0 ? "bob" : "alice")
|
||||
.withValue("cf_preferred_contact", i % 3 == 0 ? "phone" : "email"));
|
||||
}
|
||||
insert("person", people);
|
||||
Random random = new Random(8098);
|
||||
List<QRecord> orders = new ArrayList<>();
|
||||
String[] statuses = { "Draft", "Confirmed", "Processing", "Shipped", "Delivered" };
|
||||
for(int i = 0; i < 40; i++)
|
||||
{
|
||||
orders.add(new QRecord().withValue("id", i + 1).withValue("orderNo", "SO-2026-" + (1001 + i)).withValue("customer", i % 25 + 1)
|
||||
.withValue("status", statuses[i % statuses.length]).withValue("total", BigDecimal.valueOf(75000 + random.nextInt(2400000), 2))
|
||||
.withValue("orderDate", LocalDate.of(2026, 9, 1).plusDays(i)).withValue("cf_purchase_order_no", "PO-" + (8400 + i)));
|
||||
}
|
||||
insert("sales_order", orders);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Person 1 and order 1 contain the richest panels for the first demo visit.
|
||||
*******************************************************************************/
|
||||
private static void seedCollaboration() throws Exception
|
||||
{
|
||||
TagsService tags = new TagsService();
|
||||
for(String table : List.of("person", "sales_order"))
|
||||
{
|
||||
for(String label : List.of("vip", "renewal", "at-risk", "new-business"))
|
||||
{
|
||||
var tag = tags.create(table, label, label.equals("at-risk") ? "red" : "blue");
|
||||
tags.apply(table, 1, List.of(tag.id()), List.of());
|
||||
tags.apply(table, label.equals("vip") ? 2 : 3, List.of(tag.id()), List.of());
|
||||
}
|
||||
}
|
||||
QContext.setQSession(session("alice"));
|
||||
CommentsService comments = new CommentsService();
|
||||
comments.post("person", 1, "Renewal review is Thursday. @[Bob Chen](user:bob), please confirm Maya's preferred delivery window.", null, null);
|
||||
QContext.setQSession(session("bob"));
|
||||
comments.post("person", 1, "Confirmed: Tuesday mornings work best. @[Alice Morgan](user:alice), the warehouse contact is ready.", null, null);
|
||||
QContext.setQSession(session("alice"));
|
||||
var resolved = comments.post("person", 1, "Please check the purchasing title before sending the proposal.", null, "title");
|
||||
comments.post("person", 1, "Maya confirmed her new title on our call.", resolved.id(), null);
|
||||
comments.resolve(resolved.id());
|
||||
comments.post("sales_order", 1, "Annual replenishment order. @[Demo Administrator](user:admin), please review the purchase order reference.", null, null);
|
||||
comments.post("person", 2, "Expansion opportunity: add the two new Midwest locations to the renewal proposal.", null, null);
|
||||
AttachmentsService attachments = new AttachmentsService();
|
||||
attachments.upload("person", 1, "northstar-account-plan.png", "image/png", new ByteArrayInputStream(picture()), "Account plan for the renewal review");
|
||||
attachments.upload("sales_order", 1, "order-confirmation.pdf", "application/pdf", new ByteArrayInputStream(pdf()), "Draft confirmation for SO-2026-1001");
|
||||
attachments.upload("sales_order", 2, "delivery-plan.png", "image/png", new ByteArrayInputStream(picture()), "Proposed delivery schedule");
|
||||
Instant now = Instant.now();
|
||||
TasksService tasks = new TasksService();
|
||||
tasks.create("person", 1, "Send the renewal proposal", "Include the expanded delivery schedule.", "alice", now.minusSeconds(86400), null);
|
||||
tasks.create("person", 1, "Confirm warehouse contact", "Check the receiving team's direct number.", "bob", now.plusSeconds(172800), null);
|
||||
tasks.create("sales_order", 1, "Approve purchase order reference", "Match the reference on the customer's PDF.", "alice", now.plusSeconds(86400), null);
|
||||
var completed = tasks.create("person", 2, "Review regional pricing", "Use the current Midwest price list.", "alice", now.plusSeconds(86400), null);
|
||||
tasks.complete(completed.getValueInteger("id"));
|
||||
new AssignmentService().assign("sales_order", 1, "alice", "admin");
|
||||
new AssignmentService().assign("sales_order", 2, "alice", "alice");
|
||||
new SharingService().share("person", 1, List.of("bob"), "Please review the account before Thursday.");
|
||||
new SharingService().share("sales_order", 1, List.of("bob"), "Please check the delivery plan.");
|
||||
QContext.setQSession(session("bob"));
|
||||
new AccessRequestService().request("sales_order", "1", "I need order access to coordinate the Northstar delivery.", null);
|
||||
QContext.setQSession(session("alice"));
|
||||
for(String table : List.of("person", "sales_order"))
|
||||
{
|
||||
for(int id = 1; id <= 3; id++)
|
||||
{
|
||||
new FollowService().follow(table, id, true, true);
|
||||
FavoritesService.star("RECORD", table, id);
|
||||
RecentsService.recordView("RECORD", table, id);
|
||||
}
|
||||
}
|
||||
update("person", new QRecord().withValue("id", 1).withValue("title", "VP of Purchasing").withValue("cf_preferred_contact", "video"));
|
||||
update("sales_order", new QRecord().withValue("id", 1).withValue("status", "Confirmed").withValue("total", new BigDecimal("18750.00")));
|
||||
update("sales_order", new QRecord().withValue("id", 2).withValue("status", "Processing"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail startup on row-level action errors instead of showing a partial demo.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> insert(String table, List<QRecord> records) throws Exception
|
||||
{
|
||||
return (checked(new InsertAction().execute(new InsertInput(table).withRecords(records)).getRecords()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary updates generate real audit details and follow-change events.
|
||||
*******************************************************************************/
|
||||
private static void update(String table, QRecord record) throws Exception
|
||||
{
|
||||
checked(new UpdateAction().execute(new UpdateInput(table).withRecords(List.of(record))).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both insert and update may return errors without throwing an exception.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> checked(List<QRecord> records)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new IllegalStateException("Demo seed failed: " + record.getErrors()));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Generate a real image so previews need no downloaded assets.
|
||||
*******************************************************************************/
|
||||
private static byte[] picture() throws IOException
|
||||
{
|
||||
BufferedImage image = new BufferedImage(640, 320, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = image.createGraphics();
|
||||
graphics.setColor(new Color(0x12324a));
|
||||
graphics.fillRect(0, 0, 640, 320);
|
||||
graphics.setColor(new Color(0x55d6be));
|
||||
graphics.fillRect(40, 180, 160, 90);
|
||||
graphics.fillRect(240, 130, 160, 140);
|
||||
graphics.fillRect(440, 95, 160, 175);
|
||||
graphics.setColor(Color.WHITE);
|
||||
graphics.setFont(new Font("SansSerif", Font.BOLD, 26));
|
||||
graphics.drawString("Northstar | Growth plan", 40, 60);
|
||||
graphics.dispose();
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", bytes);
|
||||
return (bytes.toByteArray());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A small, valid PDF with a proper cross-reference table, generated locally.
|
||||
*******************************************************************************/
|
||||
private static byte[] pdf() throws IOException
|
||||
{
|
||||
String content = "BT /F1 18 Tf 50 740 Td (Northstar Outfitters - SO-2026-1001) Tj 0 -35 Td (Order confirmation: USD 18,750.00) Tj ET\n";
|
||||
List<String> objects = List.of("<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", "<< /Length " + content.length() + " >>\nstream\n" + content + "endstream");
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
bytes.write("%PDF-1.4\n".getBytes(StandardCharsets.US_ASCII));
|
||||
List<Integer> offsets = new ArrayList<>();
|
||||
for(int i = 0; i < objects.size(); i++)
|
||||
{
|
||||
offsets.add(bytes.size());
|
||||
bytes.write(((i + 1) + " 0 obj\n" + objects.get(i) + "\nendobj\n").getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
int start = bytes.size();
|
||||
bytes.write("xref\n0 6\n0000000000 65535 f \n".getBytes(StandardCharsets.US_ASCII));
|
||||
for(Integer offset : offsets)
|
||||
{
|
||||
bytes.write(String.format(java.util.Locale.ROOT, "%010d 00000 n \n", offset).getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
bytes.write(("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n" + start + "\n%%EOF\n").getBytes(StandardCharsets.US_ASCII));
|
||||
return (bytes.toByteArray());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Adapt the attachment HTTP test's fake to real local downloads. Opaque expiring
|
||||
** links are issued only after AttachmentsService checks parent access.
|
||||
*******************************************************************************/
|
||||
public static class MemoryStorage extends AttachmentStorage
|
||||
{
|
||||
private static final Map<String, StoredFile> objects = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Download> downloads = new ConcurrentHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain MIME type with the bytes for browser previews.
|
||||
*******************************************************************************/
|
||||
private record StoredFile(byte[] bytes, String contentType) {}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capability URLs expire just like the object-storage URLs they substitute.
|
||||
*******************************************************************************/
|
||||
private record Download(String key, String filename, Instant expires) {}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The service enforces this cap while reading the upload stream.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public long maxBytes(String name)
|
||||
{
|
||||
return (5 * 1024 * 1024);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Same bounded-stream storage approach as CollaborationApiHttpTest.MemoryStorage.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void put(String name, PutRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
objects.put(request.getKey(), new StoredFile(request.getStream().readAllBytes(), request.getContentType()));
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Cannot store demo attachment", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deleting the object also makes any outstanding download links unusable.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void delete(String name, String key)
|
||||
{
|
||||
objects.remove(key);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Issue a local URL on the configured demo port after the access check.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public URL presign(String name, PresignGet request)
|
||||
{
|
||||
downloads.entrySet().removeIf(entry -> entry.getValue().expires().isBefore(Instant.now()));
|
||||
String token = UUID.randomUUID().toString();
|
||||
downloads.put(token, new Download(request.getKey(), request.getDownloadAs(), Instant.now().plus(request.getTtl())));
|
||||
try
|
||||
{
|
||||
return (URI.create("http://localhost:" + System.getProperty("collab.dev.port", "8098") + "/demo-files/" + token).toURL());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serve only issued capabilities; never expose an object-key listing.
|
||||
*******************************************************************************/
|
||||
static void routes(JavalinConfig config)
|
||||
{
|
||||
config.routes.get("/demo-files/{token}", context ->
|
||||
{
|
||||
Download download = downloads.get(context.pathParam("token"));
|
||||
StoredFile file = download == null ? null : objects.get(download.key());
|
||||
if(download == null || !download.expires().isAfter(Instant.now()) || file == null)
|
||||
{
|
||||
context.status(404);
|
||||
return;
|
||||
}
|
||||
context.contentType(file.contentType() == null ? "application/octet-stream" : file.contentType());
|
||||
context.header("Cache-Control", "no-store");
|
||||
context.header("X-Content-Type-Options", "nosniff");
|
||||
context.header("Content-Disposition", download.filename() == null ? "inline" : "attachment; filename*=UTF-8''" + java.net.URLEncoder.encode(download.filename(), StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
context.result(file.bytes());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep the smoke test isolated from the other in-memory service tests.
|
||||
*******************************************************************************/
|
||||
static void clear()
|
||||
{
|
||||
objects.clear();
|
||||
downloads.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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.devserver;
|
||||
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentsService;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qbits.collaboration.recents.RecentsService;
|
||||
import com.kingsrook.qbits.collaboration.sharing.SharingService;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagsService;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineService;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinImplementation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the real demo seed, permissions, audit history and local file URLs.
|
||||
*******************************************************************************/
|
||||
class CollaborationDevServerTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Build without frontend assets, then smoke the same server on a free port.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seededDemoValidatesAndServesCollaboration() throws Exception
|
||||
{
|
||||
String oldUser = System.getProperty("collab.dev.user");
|
||||
String oldPort = System.getProperty("collab.dev.port");
|
||||
QInstance instance = null;
|
||||
QApplicationJavalinServer server = null;
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
CollaborationDevServer.MemoryStorage.clear();
|
||||
try(HttpClient client = HttpClient.newHttpClient())
|
||||
{
|
||||
System.setProperty("collab.dev.user", "alice");
|
||||
instance = CollaborationDevServer.buildInstance();
|
||||
CollaborationDevServer.seed(instance);
|
||||
new QInstanceValidator().validate(instance);
|
||||
assertThat(rows("person")).hasSize(25);
|
||||
assertThat(rows("sales_order")).hasSize(40);
|
||||
assertThat(rows("person")).anySatisfy(row ->
|
||||
{
|
||||
assertThat(row.getValueInteger("id")).isEqualTo(1);
|
||||
assertThat(row.getValueString("cf_preferred_contact")).isEqualTo("video");
|
||||
});
|
||||
assertThat(rows("sales_order")).allSatisfy(row -> assertThat(row.getValueString("cf_purchase_order_no")).startsWith("PO-"));
|
||||
assertThat(instance.getTable("person").getField("cf_preferred_contact").getBackendStorage()).isInstanceOf(JsonPathStorage.class);
|
||||
assertThat(instance.getTable("sales_order").getField("cf_purchase_order_no").getBackendStorage()).isInstanceOf(JsonPathStorage.class);
|
||||
assertThat(instance.getTable("sales_order").getSearchFields()).contains("cf_purchase_order_no");
|
||||
for(String table : List.of("person", "sales_order"))
|
||||
{
|
||||
var features = RecordFeaturesResolver.resolve(instance, instance.getTable(table)).getFeatures();
|
||||
for(RecordFeature feature : RecordFeature.values())
|
||||
{
|
||||
assertThat(features.get(feature).getEnabled()).as(table + " " + feature).isTrue();
|
||||
}
|
||||
assertThat(new TagsService().tagsFor(table, 1)).hasSize(4);
|
||||
}
|
||||
assertThat(new TagsService().mode("person")).isEqualTo("FREE");
|
||||
assertThat(new TagsService().mode("sales_order")).isEqualTo("APPROVED_ONLY");
|
||||
assertThat(FavoritesService.list()).hasSize(6);
|
||||
assertThat(RecentsService.list(20)).hasSize(6);
|
||||
assertThat(new TimelineService().timeline("person", 1, null, 100, null).items())
|
||||
.anySatisfy(item -> assertThat(item.details()).anySatisfy(detail ->
|
||||
{
|
||||
assertThat(detail.fieldName()).isEqualTo("title");
|
||||
assertThat(detail.oldValue()).isEqualTo("Purchasing Director");
|
||||
assertThat(detail.newValue()).isEqualTo("VP of Purchasing");
|
||||
}));
|
||||
assertThat(new SharingService().preview("person", 1, List.of("bob")).getFirst().canRead()).isTrue();
|
||||
assertThat(new SharingService().preview("sales_order", 1, List.of("bob")).getFirst().canRead()).isFalse();
|
||||
QContext.setQSession(CollaborationDevServer.session("admin"));
|
||||
assertThat(rows(CollaborationTables.USER)).hasSize(3);
|
||||
assertThat(rows(CollaborationTables.FIELD_DEFINITION)).hasSize(2).allSatisfy(row -> assertThat(row.getValueString("status")).isEqualTo("ACTIVE"));
|
||||
assertThat(rows(CollaborationTables.TAG)).hasSize(8);
|
||||
assertThat(rows(CollaborationTables.ATTACHMENT)).hasSize(3);
|
||||
assertThat(rows(CollaborationTables.COMMENT)).hasSize(6).anySatisfy(row -> assertThat(row.getValue("resolvedAt")).isNotNull());
|
||||
assertThat(rows(CollaborationTables.MENTION)).hasSize(3);
|
||||
assertThat(rows(CollaborationTables.TASK)).hasSize(4).anySatisfy(row ->
|
||||
{
|
||||
assertThat(row.getValueString("status")).isEqualTo("OPEN");
|
||||
assertThat(row.getValueInstant("dueAt")).isBefore(Instant.now());
|
||||
});
|
||||
assertThat(rows(CollaborationTables.SHARE)).hasSize(2);
|
||||
assertThat(rows(CollaborationTables.RECORD_ASSIGNMENT)).hasSize(2);
|
||||
assertThat(rows(CollaborationTables.ACCESS_REQUEST)).singleElement().satisfies(row -> assertThat(row.getValueString("status")).isEqualTo("OPEN"));
|
||||
assertThat(rows("auditDetail")).isNotEmpty();
|
||||
for(String user : CollaborationDevServer.USERS)
|
||||
{
|
||||
System.setProperty("collab.dev.user", user);
|
||||
QSession mock = new MockAuthenticationModule().createSession(instance, Map.of("sessionUUID", UUID.randomUUID().toString()));
|
||||
assertThat(mock.getUser().getIdReference()).isEqualTo(user);
|
||||
assertThat(mock.getPermissions()).isEqualTo(CollaborationDevServer.session(user).getPermissions());
|
||||
}
|
||||
assertThatThrownBy(() -> CollaborationDevServer.session("unknown")).isInstanceOf(IllegalArgumentException.class);
|
||||
System.setProperty("collab.dev.user", "alice");
|
||||
int port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
System.setProperty("collab.dev.port", Integer.toString(port));
|
||||
server = CollaborationDevServer.server(instance, port, false);
|
||||
server.start();
|
||||
String base = "http://localhost:" + port;
|
||||
assertThat(get(client, base + "/qqq/v1/version").statusCode()).isEqualTo(200);
|
||||
var response = get(client, base + "/qqq/v1/qbit/collaboration/records/person/1/comments");
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
assertThat(new ObjectMapper().readTree(response.body()).get("threads")).hasSize(3);
|
||||
QContext.init(instance, CollaborationDevServer.session("alice"));
|
||||
AttachmentsService attachments = new AttachmentsService();
|
||||
var png = attachments.list("person", 1).getFirst();
|
||||
var image = get(client, attachments.downloadUrl(png.id(), true).toString());
|
||||
assertThat(image.statusCode()).isEqualTo(200);
|
||||
assertThat(image.headers().firstValue("Content-Type").orElseThrow()).startsWith("image/png");
|
||||
assertThat(javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(image.body()))).isNotNull();
|
||||
var pdf = attachments.list("sales_order", 1).getFirst();
|
||||
var document = get(client, attachments.downloadUrl(pdf.id(), false).toString());
|
||||
assertThat(document.statusCode()).isEqualTo(200);
|
||||
assertThat(new String(document.body(), java.nio.charset.StandardCharsets.US_ASCII)).startsWith("%PDF-1.4");
|
||||
assertThat(document.headers().firstValue("Content-Disposition").orElseThrow()).contains("attachment", "order-confirmation.pdf");
|
||||
System.setProperty("collab.dev.user", "bob");
|
||||
assertThat(get(client, base + "/qqq/v1/qbit/collaboration/records/person/1/comments").statusCode()).isEqualTo(200);
|
||||
assertThat(get(client, base + "/qqq/v1/qbit/collaboration/records/sales_order/1/comments").statusCode()).isEqualTo(404);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(server != null)
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
if(instance != null)
|
||||
{
|
||||
PresenceRegistry.close(instance);
|
||||
RealtimeBus.close(instance);
|
||||
}
|
||||
QContext.clear();
|
||||
QJavalinImplementation.setSessionCookieHttpOnly(false);
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
CollaborationDevServer.MemoryStorage.clear();
|
||||
restore("collab.dev.user", oldUser);
|
||||
restore("collab.dev.port", oldPort);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read through normal permissions, including the seeded admin-only tables.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> rows(String table) throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table)).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each request gets a fresh MOCK session with the currently selected user.
|
||||
*******************************************************************************/
|
||||
private static HttpResponse<byte[]> get(HttpClient client, String url) throws Exception
|
||||
{
|
||||
return (client.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(20))
|
||||
.header("Cookie", "sessionUUID=" + UUID.randomUUID()).GET().build(), HttpResponse.BodyHandlers.ofByteArray()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not leak demo properties into the rest of the test suite.
|
||||
*******************************************************************************/
|
||||
private static void restore(String name, String value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
System.clearProperty(name);
|
||||
}
|
||||
else
|
||||
{
|
||||
System.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user