Merge develop into release/0.1 for 0.1.0-RC.6 (#29 UI API 1.6.0 extension, QQQ RC.15)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Opus 5.5 committed 2026-10-15 16:17:44 -05:00
commit c4eb972d3c
14 files changed
+1444 -208

No files matched your search

+4
View File
@@ -13,6 +13,8 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Changed
- UI API extension collaboration 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and published presence request/snapshot/viewer schemas to resolve dangling references. Retain CollaborationErrorV1 as deprecated.
- 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.
@@ -24,6 +26,8 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Fixed
- QBit metadata reports the Maven artifact version from a filtered resource, including release versions.
- Timeline audit summaries read "Edited by Alice Morgan: Title, Total" or "Created by Alice Morgan" instead of "Record was Edited"; detail rows are unchanged.
- The demo server chooses its user per browser session (`?user=bob`, remembered in a cookie) instead of once per JVM, and sends the person's name to the frontend so it no longer shows Anonymous.
- The share recipient search and mention candidates no longer offer the caller to themself.
+10 -8
View File
@@ -410,13 +410,13 @@ The runtime provider adds this field and its search entry where TAGS is enabled.
## UI API
The producer registers extension `collaboration` version `1.0.0` at
The producer registers extension `collaboration` version `1.1.0` at
`/qqq/v1/qbit/collaboration`. It uses the signed-in user's session and existing
services. Disable registration with `new CollaborationQBitConfig().withUiApi(false)`.
Presence, shares, tasks, my-work, direct upload and thumbnail routes are not part
of this extension version.
Requires QQQ `4.1.0-RC.15` and UI API `1.6.0`. Operations carry stable operationIds,
the collaboration tag and standard error responses.
All successes return HTTP 200. All bodies have named `Collaboration*V1` schemas
All successes return HTTP 200. Success and request bodies have named `Collaboration*V1` schemas
in [the extension contract](src/main/resources/openapi/qqq-v1/collaboration.yaml).
The server publishes these alongside core at `/qqq/v1/openapi.yaml` and `.json`.
@@ -478,9 +478,11 @@ attachment filename; preview requests inline content. QQQ's current OpenAPI
`Response` model cannot declare the `Location` header, so this contract uses the
JSON URL alternative consistently.
A missing or invisible parent returns identical `404 {"error":"Not found"}`.
Missing write permissions return 403; invalid input returns 400 with an `error`
message through QQQ's exception mapper. Provider/backend failures remain server
A missing or invisible parent returns 404 with the same `Not found` error message.
Missing write permissions return 403; invalid input returns 400 through QQQ's
exception mapper. Errors use `BasicErrorResponseV1` with `error`, `code`, `message`,
`traceId` and optional `fieldErrors`. `CollaborationErrorV1` remains defined but is
deprecated since 1.1.0. Provider/backend failures remain server
errors. Services continue to own author, record-lock and permission checks.
Mention candidates expose `canRead` as the string enum `"true"`, `"false"`,
`"unknown"`; unknown does not imply the recipient can read the record.
@@ -636,7 +638,7 @@ New routes, all under `/qqq/v1/qbit/collaboration`:
| PUT | `/records/{table}/{pk}/assignment` |
| GET | `/my-work` |
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.
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 is now **1.1.0**, including the UI API 1.6.0 error contract and published presence schemas.
`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.
+31 -2
View File
@@ -113,7 +113,36 @@
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-collaboration.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-collaboration.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<!-- the artifact version, for the help bundle version test -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -196,14 +225,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.7 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.15 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.7</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -22,6 +22,9 @@
package com.kingsrook.qbits.collaboration;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.attachments.DeleteAttachmentsProcess;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
@@ -44,13 +47,41 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-collaboration";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private CollaborationQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Read the artifact version from the Maven-filtered properties resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = CollaborationQBitProducer.class.getClassLoader().getResourceAsStream("qbit-collaboration.properties"))
{
if(inputStream == null)
{
throw new IllegalStateException("Missing qbit-collaboration.properties; build with Maven to generate the artifact version.");
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw new IllegalStateException("Missing or unfiltered version in qbit-collaboration.properties; build with Maven resource filtering.");
}
return (version);
}
catch(IOException e)
{
throw new IllegalStateException("Could not read the artifact version from qbit-collaboration.properties.", e);
}
}
/*******************************************************************************
** Configure the qbit.
*******************************************************************************/
@@ -52,7 +52,7 @@ public final class CollaborationApi
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0")
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.1.0")
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence")
.withSpec(PostPresenceSpec.class).withSpec(DeletePresenceSpec.class).withSpec(GetPresenceSpec.class)
.withSpec(ListFavoritesSpec.class)
@@ -46,6 +46,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
@@ -112,10 +113,10 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
*******************************************************************************/
static List<BasicResponse> errors()
{
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", "CollaborationErrorV1")));
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", BasicErrorResponseV1.class.getSimpleName())));
}
/*******************************************************************************
@@ -514,7 +514,7 @@ public final class CollaborationSchemas
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Standard QQQ error response.").withProperties(new TreeMap<>(Map.ofEntries(
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("error", new Schema().withType(Type.STRING).withDescription("Human-readable reason the operation failed.").withNullable(true))))));
}
}
@@ -714,6 +714,10 @@ public final class CollaborationSchemas
schemas.put("CollaborationCommentPostRequestV1", new CollaborationCommentPostRequestV1(null, null, null).toSchema());
schemas.put("CollaborationCommentEditRequestV1", new CollaborationCommentEditRequestV1(null).toSchema());
schemas.put("CollaborationFollowRequestV1", new CollaborationFollowRequestV1(null, null).toSchema());
schemas.put("CollaborationPresenceRequestV1", new CollaborationPresenceSchemas.CollaborationPresenceRequestV1(null, null, null, null, null).toSchema());
schemas.put("CollaborationPresenceLeaveV1", new CollaborationPresenceSchemas.CollaborationPresenceLeaveV1(null, null, null).toSchema());
schemas.put("CollaborationPresenceSnapshotV1", new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(null, null, null).toSchema());
schemas.put("CollaborationPresenceViewerV1", new CollaborationPresenceSchemas.CollaborationPresenceViewerV1(null, null, null, null).toSchema());
schemas.putAll(CollaborationWorkSchemas.components());
return (schemas);
}
@@ -1,5 +1,5 @@
bundle: qbit-collaboration
version: 0.1.0-SNAPSHOT
version: ${project.version}
layer: QBIT
entries:
- key: widget:collaborationKpis;slot:label
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
version=${project.version}
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -56,7 +57,7 @@ class CollaborationApiContractTest
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
"mvnw.cmd -o -q -Dtest=CollaborationApiContractTest -Dcollaboration.openapi.update=true test")).isEmpty();
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration"));
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration") || name.equals(BasicErrorResponseV1.class.getSimpleName()));
assertThat(model.getPaths().keySet()).allMatch(pathName -> pathName.startsWith("/qqq/v1/qbit/collaboration/"));
assertThat(generated).contains("multipart/form-data", "format: \"binary\"", "clients follow url", "nextCursor");
assertThat(extension.getSpecs()).hasSize(45);
@@ -37,6 +37,7 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
@@ -431,8 +432,16 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
HttpResponse<String> hidden = request("GET", "/records/document/3/" + feature, null, "alice");
HttpResponse<String> absent = request("GET", "/records/document/999/" + feature, null, "alice");
assertThat(hidden.statusCode()).isEqualTo(404);
assertThat(hidden.body()).isEqualTo(absent.body());
assertThat(json(hidden, 404).get("error").asText()).isEqualTo("Not found");
ObjectNode hiddenError = (ObjectNode) json(hidden, 404);
ObjectNode absentError = (ObjectNode) json(absent, 404);
assertThat(hiddenError.path("traceId").asText()).isNotBlank();
assertThat(absentError.path("traceId").asText()).isNotBlank();
hiddenError.remove("traceId");
absentError.remove("traceId");
assertThat(hiddenError).isEqualTo(absentError);
assertThat(hiddenError.path("error").asText()).isEqualTo("Not found");
assertThat(hiddenError.path("code").asText()).isEqualTo("NOT_FOUND");
assertThat(hiddenError.path("message").asText()).isEqualTo("Not found");
}
assertError(404, "POST", "/records/document/3/comments", "not json", "alice");
assertError(404, "GET", "/mentions/candidates?table=document&pk=3", null, "alice");
@@ -500,15 +509,19 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
JsonNode version = json(client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/qqq/v1/version")).GET().build(),
HttpResponse.BodyHandlers.ofString()), 200);
assertThat(version.at("/extensions/0/name").asText()).isEqualTo("collaboration");
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.0.0");
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.1.0");
}
/*******************************************************************************
** Framework errors always contain a human-readable error property.
** Framework errors include the standard message, code and request trace.
*******************************************************************************/
private void assertError(int status, String method, String path, String body, String caller) throws Exception
{
assertThat(json(request(method, path, body, caller), status).get("error").asText()).isNotBlank();
var error = json(request(method, path, body, caller), status);
assertThat(error.path("error").asText()).isNotBlank();
assertThat(error.path("code").asText()).isNotBlank();
assertThat(error.path("message").asText()).isNotBlank();
assertThat(error.path("traceId").asText()).isNotBlank();
}
/*******************************************************************************
@@ -146,7 +146,7 @@ class CollaborationTablesGuardrailTest extends PersonalServiceFixture
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-collaboration");
assertThat(bundle.getVersion()).isEqualTo("0.1.0-SNAPSHOT");
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CollaborationQBitProducer.VERSION);
assertThat(bundle.getEntries()).anyMatch(entry -> entry.getKey().equals("app:collaboration;slot:help"));
});
}
@@ -146,7 +146,7 @@ class CollaborationTest
assertThat(producer.isEnabled()).isTrue();
assertThat(producer.getQBitConfig()).isSameAs(config);
assertThat(producer.getNamespace()).isEqualTo("collaboration");
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo("0.1.0");
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CollaborationQBitProducer.VERSION);
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-collaboration");
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("memory");
assertThat(config.getAdminApp()).isTrue();