Compare commits

...
Author SHA1 Message Date
james.maes 95506ed706 Merge branch 'feature/presence-custom-fields' into feature/demo-dev-server 2026-10-13 22:19:51 -05:00
james.maes bc992fd5f8 Merge branch 'feature/ui-api-endpoints' into feature/presence-custom-fields
# Conflicts:
#	src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java
2026-10-13 22:19:42 -05:00
james.maesandClaude Opus 5.5 7b7c921079 test: collaboration demo dev server with seeded Sales data, plus a SELECT custom-field refresh fix
CollaborationDevServer serves the Next dashboard over an in-memory
instance (people, orders, every record feature on, seeded comments,
files, tags, custom fields, tasks, shares, audit history) with mock users
alice, bob and admin. CustomFieldsProvider now registers generated SELECT
possible value sources on refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:04:03 -05:00
james.maesandClaude Opus 5.5 08498dd1db feat: custom field designer (JSONB + EAV fallback, managed indexes) and presence
collaboration_field_definition (with DataClassification) and
collaboration_field_value; CustomFieldsProvider contributes ACTIVE fields
through the runtime overlay with JsonPathStorage (Postgres, memory) or a
read-only EAV virtual field elsewhere; Publish/Archive/Rebuild index
actions; the collaborationBuildFieldIndexes job creates expression
indexes CONCURRENTLY (or records the DDL when unmanaged). Presence:
PresenceRegistry replicated over the realtime bus with a 45 s TTL and a
cap, presence.changed events, and POST/DELETE/GET /presence endpoints;
an admin Who's here widget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:09:49 -05:00
42 changed files with 4419 additions and 66 deletions

No files matched your search

+5 -1
View File
@@ -8,7 +8,11 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Added
- Collaboration UI API extension `1.0.0`: 42 typed operations for favorites, recents, attachments, tags, comments, mentions, follow, timeline, people, sharing, access requests, tasks, assignment and My work under `/qqq/v1/qbit/collaboration`, enabled by default and disabled with `withUiApi(false)`.
- Phase f custom-field designer: audited definitions, secured EAV children, runtime JSON-path fields, classification, validation, lifecycle actions, PostgreSQL probes/index jobs and migration DDL. Read-only EAV filters and scalar multi-select limitations are documented.
- Phase g presence: typed POST/DELETE/GET endpoints, 45-second leases, tab merging, a 50,000-entry cap, internal bus replication, record/table events, summaries and the admin-only Who's here card.
- Designer, HTTP, two-pod, clock and JDBC-boundary tests; regenerated contract and existing Checkstyle/JaCoCo gates retained.
- Collaboration UI API extension `1.0.0`: 45 typed operations for favorites, recents, attachments, tags, comments, mentions, follow, timeline, people, sharing, access requests, tasks, assignment, My work and presence under `/qqq/v1/qbit/collaboration`, enabled by default and disabled with `withUiApi(false)`.
- Real multipart uploads; download/preview return five-minute `{url}` responses (clients follow url) because the framework cannot describe a redirect Location header. Configurable attachment storage adapter supports in-memory HTTP tests.
- Versioned `ToSchema` wire types, enum values, semantic parameter descriptions, generated OpenAPI fragment and drift check; real HTTP tests cover all operations, paging, parent visibility, permissions, validation and storage failures.
- Shared HTTP page bounds of 1-100 (default 20); comments/timeline keyset cursors and offset cursors for complete-list services. People/vocabulary remain bounded searches, while recents/mention candidates retain their 50-item service windows.
+171 -1
View File
@@ -1,6 +1,6 @@
# QBit Collaboration
Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and core-integrated tag filtering/search. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g remain planned. Runtime overrides and TAGS mode settings resolve through core.
Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and core-integrated tag filtering/search. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g add the custom-field designer and ephemeral replicated presence. Runtime overrides and TAGS mode settings resolve through core.
Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md).
@@ -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):
@@ -510,3 +578,105 @@ 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.
## Custom fields (phase f)
Enable `RecordFeature.CUSTOM_FIELDS` on each host table. **Custom Fields** is the standard,
FIELD-audited `collaboration_field_definition` table. Reads require `collaboration.view` (or
admin); writes/actions require `collaboration.fields.manage` (or admin). Definitions have a
unique `(tableName, name)` key. The CUSTOM table picker lists enabled host tables; this core
snapshot has no generic table-name provider. EAV values are a secured child list, not a nav item.
Create a DRAFT named `[a-z][a-z0-9_]{0,40}`, then run **Publish field**. ACTIVE definitions appear
as ordinary `cf_<name>` fields through the overlay. **Archive field** counts records with values
in Review and hides the field, retaining its values. This version publishes DRAFT definitions
only. **Rebuild index** reruns the index job. Types can change only in DRAFT; table/name identity
is immutable. Lifecycle/index fields are server-managed; published definitions cannot be deleted.
Types: STRING, TEXT, INTEGER, DECIMAL, BOOLEAN, DATE, DATE_TIME, SELECT, MULTI_SELECT, USER and
RECORD_LINK. Select options use stable ids, for example:
```json
[{"id":"normal","label":"Normal"},{"id":"urgent","label":"Urgent"}]
```
Active options may be added or relabeled. PostgreSQL containment counts refuse removal of an id
still in use; other backends skip this check with a visible warning. USER uses `collaboration_user`
PVS; RECORD_LINK requires the linked table's PVS. MULTI_SELECT is a JSON-array **STRING** with a
PVS: native array membership operators and multi-select editing await core support. Numeric
bounds use `ValueRangeBehavior`; required, maxLength, defaultValue and plain-text help use normal
metadata. Optional PUBLIC/CONFIDENTIAL/PII/PHI classification is carried by `AiFieldMetaData`.
Sections follow `sectionName`, defaulting to Custom fields. `sortOrder` controls contribution
order; searchable definitions join the normal search-field list.
Definition writes refresh metadata after commit; rollbacks do not. Perform the initial
`RuntimeMetaDataOverlay.refresh(instance)` after metadata and database initialization. Other pods
must refresh their overlays when definitions change; presence replication does not replicate
metadata.
Memory uses core `JsonPathStorage`. PostgreSQL uses it after a cached
`SELECT qqq_custom_fields FROM <backend table> LIMIT 0` probe succeeds. Missing columns and
unsupported backends get read-only EAV virtual filters over `collaboration_field_value`, with
typed `QSubQuery` criteria. These fields are not query-selectable; EAV value writes and projection
are outside this version. `statusMessage` records the fallback reason. After adding a missing
column, call `CustomFieldStorage.invalidate(instance, tableName)` and refresh the overlay.
### PostgreSQL setup and indexes
Apply the one-time migration with your normal migration tool:
```sql
ALTER TABLE orders ADD COLUMN IF NOT EXISTS qqq_custom_fields JSONB NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS orders_custom_fields_gin ON orders
USING gin (qqq_custom_fields jsonb_path_ops);
```
`CollaborationSchema.customFieldsDdl("orders")` generates equivalent SQL with quoted identifiers
and a deterministic index name. `postgresScript(config)` includes the two new collaboration
tables. Neither helper executes SQL. Do not declare the backing column as a regular QQQ field.
The hidden `collaborationBuildFieldIndexes` job runs every 60 seconds when `schedulerName` is
set, and through Publish/Rebuild/Archive. Definitions show BUILDING/READY/FAILED, SQL and errors.
Range fields get typed expression indexes; searchable text uses `gin_trgm_ops` when `pg_trgm`
is installed, otherwise a normal expression index. Concurrent DDL uses a fresh backend
connection with autocommit, outside the record transaction. PostgreSQL can reject temporal text
casts as non-immutable; that failure is recorded. A host migration can supply its chosen
immutable expression. Rebuild uses `IF NOT EXISTS`, preserving existing index definitions.
Archive drops the index concurrently while retaining values.
`withManagedIndexes(false)` records `indexDdl` only, with indexState NONE, for host migrations.
There is no automatic migration receipt. The home shows definition/index status totals and the
number of definitions with diagnostic messages.
## Presence (phase g)
Enable `RecordFeature.PRESENCE` on the table. The typed endpoints below
`/qqq/v1/qbit/collaboration` are:
| Operation | Input |
| --- | --- |
| `POST /presence` | `{connectionId, table, pk?, mode: "VIEWING"|"EDITING", fieldName?}` |
| `DELETE /presence` | `{connectionId, table, pk?}` |
| `GET /presence?table=...&pk=...` | Omit pk for a table snapshot |
Session identity supplies userId and displayName. Every call rechecks table READ and record
GetAction visibility. Disabled features and denied/missing parents return the same 404. A
refused renewal removes the caller's previous lease. Connection ids are namespaced by user;
they need not be active SSE connections, allowing polling clients to participate.
Renew on open/focus change and every 20 seconds. Leases expire after 45 seconds, with a one-second
sweep and expiry checks on reads. The cap is 50,000 leases per pod: new targets are refused with
a warning while existing leases can renew. Tabs merge per user; EDITING wins and the latest
editor supplies focus. Nothing is persisted, including directory rows.
Every heartbeat/leave replicates over core's bus on `instance:presence`, a channel refused by
core client subscriptions. `presence.changed` is emitted to record and table channels. Table
snapshots aggregate users without record ids or focused field names; those stay on record
channels. Configure core's ESB bus for multiple pods. Delivery is best effort; clients renew and
refetch after reconnecting.
The initial overlay refresh starts the registry on every pod, including those without local
viewers. Endpoints also initialize it lazily. At shutdown call `PresenceRegistry.close(instance)`
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user counts
per table, and core record-feature summaries include the presence snapshot.
+36
View File
@@ -126,3 +126,39 @@ Feature writes refresh the overlay after commit. Initial loading follows core's
`CollaborationWorkSchemas` and endpoint specs expose sharing, access requests, tasks, assignment and My work; the README and generated OpenAPI contain the route list. Task PATCH validates before writing and preserves omitted fields. Access requests never inspect parent existence. Contract version remains unreleased 1.0.0.
`CollaborationRealtime` merges stored references for partial updates, captures immutable identifier payloads and publishes after commit. Notification hints follow actual delivery. Rollback discards callbacks. Bus failure is best-effort and does not fail a committed write.
## Custom fields and presence
`CustomFieldsProvider` contributes ACTIVE definitions through core's runtime overlay. Tags and
custom fields share incoming feature-policy resolution, so a persisted setting takes effect in
the same refresh. FIELD-audited definitions enforce management permissions and lifecycle rules
on both direct writes and streamed actions. Numeric range behavior is added only when a bound
exists. Classification stays in core AiFieldMetaData. Select option ids are stable across label
changes, with parameterized PostgreSQL containment checks before removing active options.
Storage availability is cached per instance/table. Memory needs no schema; PostgreSQL uses a
zero-row probe through the backend connection provider. Failure installs a visible read-only
EAV filter instead of a broken JSON field. Typed subqueries retain parent permissions/locks;
EAV values are neither projected nor written by this PR. The child table declares core reference
security and participates in parent cleanup. MULTI_SELECT is a JSON-array scalar string until
core adds native array storage/filter contracts.
Index building is separate from overlay construction. Concurrent DDL runs through independent
autocommit connections and records its statement/state/error on the definition. A no-DDL option
records migration statements only. No column/table DDL runs during refresh. JDBC-boundary tests
cover probe caching, option containment, autocommit and failure reporting. Live PostgreSQL
acceptance remains necessary for host index expressions, especially temporal casts.
Presence is the only non-table state. Pods subscribe at initial overlay refresh and keep bounded
user/connection/target leases. `instance:presence` is denied by core's client channel authorizer.
State mutation and bus publication occur outside each other's monitor, preventing deadlock on
synchronous bus fan-out. Wire data uses transport-safe maps. Record focus stays on record
channels; table events omit pk and fieldName. Each HTTP request rechecks parent visibility and
PRESENCE; session identity is authoritative and the directory is never touched. Hosts close the
registry before the core bus.
Limits: EAV writes/projection, native multi-select operations, automatic cross-pod metadata
refresh, replacing existing index definitions, and live PostgreSQL acceptance are not provided.
The no-DDL option does not infer whether an external migration has run. Presence relies on a
trusted instance bus and best-effort delivery; renewed heartbeats restore state after outages.
+4
View File
@@ -67,6 +67,10 @@
</scm>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-object-storage</artifactId>
@@ -42,7 +42,7 @@ public class CollaborationCleanup implements RecordReferenceCleanupInterface
public void onParentsDeleted(String tableName, List<Serializable> primaryKeys) throws QException
{
new AttachmentCleanup().onParentsDeleted(tableName, primaryKeys);
for(String table : List.of(CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION, CollaborationTables.COMMENT,
for(String table : List.of(CollaborationTables.FIELD_VALUE, CollaborationTables.COMMENT_REVISION, CollaborationTables.MENTION, CollaborationTables.COMMENT,
CollaborationTables.FOLLOW_DIGEST, CollaborationTables.FOLLOW, CollaborationTables.ACTIVITY))
{
while(true)
@@ -57,6 +57,7 @@ public class CollaborationQBitConfig implements QBitConfig
private List<String> assignActionTables = List.of();
private String backendName;
private String schedulerName;
private Boolean managedIndexes = true;
private String appName = "collaboration";
private String parentAppName;
private Boolean adminApp = true;
@@ -629,4 +630,29 @@ public class CollaborationQBitConfig implements QBitConfig
mirroredSecurityKeyTypes = List.copyOf(types);
return (this);
}
/*******************************************************************************
** Whether the index job executes DDL or only records migration statements.
*******************************************************************************/
public Boolean getManagedIndexes()
{
return (managedIndexes);
}
/*******************************************************************************
** Configure host-managed migrations instead of runtime index creation.
*******************************************************************************/
public CollaborationQBitConfig withManagedIndexes(Boolean managedIndexes)
{
this.managedIndexes = managedIndexes;
return (this);
}
/*******************************************************************************
** Bean configuration support.
*******************************************************************************/
public void setManagedIndexes(Boolean managedIndexes)
{
this.managedIndexes = managedIndexes;
}
}
@@ -128,6 +128,8 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
CollaborationSecurity.buildSecurityKeyTypes().forEach(output::add);
output.add(TrimRecentsProcess.metadata(qBitConfig));
output.add(com.kingsrook.qbits.collaboration.customfields.BuildFieldIndexes.metadata(qBitConfig));
com.kingsrook.qbits.collaboration.customfields.FieldProcesses.metadata().forEach(output::add);
output.add(com.kingsrook.qbits.collaboration.tasks.SendTaskRemindersProcess.metadata(qBitConfig));
WorkProcesses.management().forEach(output::add);
for(String table : qBitConfig.getAssignActionTables())
@@ -119,4 +119,16 @@ public final class CollaborationSchema
default -> throw new IllegalArgumentException("Unsupported schema type: " + field.getType());
});
}
/*******************************************************************************
** Host-owned one-time PostgreSQL migration; this helper never executes SQL.
*******************************************************************************/
public static String customFieldsDdl(String tableName)
{
String table = java.util.Arrays.stream(tableName.split("\\.")).map(com.kingsrook.qbits.collaboration.customfields.CustomFieldStorage::identifier)
.collect(java.util.stream.Collectors.joining("."));
String index = "cf_gin_" + Integer.toUnsignedString(tableName.hashCode(), 16);
return ("ALTER TABLE " + table + " ADD COLUMN IF NOT EXISTS qqq_custom_fields JSONB NOT NULL DEFAULT '{}';\n"
+ "CREATE INDEX IF NOT EXISTS " + index + " ON " + table + " USING gin (qqq_custom_fields jsonb_path_ops);\n");
}
}
@@ -47,6 +47,18 @@ public class CollaborationSummaryProvider implements RecordFeatureSummaryProvide
var features = RecordFeaturesResolver.resolve(QContext.getQInstance(), QContext.getQInstance().getTable(ref.tableName()));
String id = parent.getValueString(QContext.getQInstance().getTable(ref.tableName()).getPrimaryKeyField());
String user = PersonalRecords.currentUserId();
if(features.isEnabled(RecordFeature.PRESENCE))
{
summary.setPresence(com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).snapshot(ref.tableName(), id).stream().map(viewer ->
{
java.util.Map<String, java.io.Serializable> entry = new java.util.LinkedHashMap<>();
entry.put("userId", viewer.userId());
entry.put("displayName", viewer.displayName());
entry.put("mode", viewer.mode());
entry.put("fieldName", viewer.fieldName());
return (entry);
}).toList());
}
if(features.isEnabled(RecordFeature.FAVORITES))
{
summary.setIsFavorite(!CollaborationRecords.query(CollaborationTables.FAVORITE, new QQueryFilter()
@@ -41,6 +41,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
public final class CoreCollaboration
{
private static final Map<String, RecordFeature> REFERENCES = Map.ofEntries(
Map.entry(CollaborationTables.FIELD_VALUE, RecordFeature.CUSTOM_FIELDS),
Map.entry(CollaborationTables.ATTACHMENT, RecordFeature.ATTACHMENTS),
Map.entry(CollaborationTables.RECORD_TAG, RecordFeature.TAGS),
Map.entry(CollaborationTables.COMMENT, RecordFeature.COMMENTS),
@@ -69,6 +70,7 @@ public final class CoreCollaboration
var features = RecordFeaturesMetaData.ofOrWithNew(instance);
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));
features.getSummaryProviders().add(new QCodeReference(CollaborationSummaryProvider.class));
var security = RecordReferenceSecurityConfig.ofOrWithNew(instance).withMirroredSecurityKeyTypes(config.getMirroredSecurityKeyTypes());
security.getCleanupProviders().add(new QCodeReference(CollaborationCleanup.class));
@@ -35,6 +35,8 @@ 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.RecordFeatureOverride;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Read persisted feature policy during core overlay refresh.
@@ -72,4 +74,55 @@ public class FeatureSettingsProvider implements RecordFeatureOverrideProviderInt
}
return (result);
}
/*******************************************************************************
** Resolve a feature against the incoming policy in core's instance/app/table order.
*******************************************************************************/
public static boolean enabled(QInstance instance, QTableMetaData table, RecordFeature feature, List<RecordFeatureOverride> overrides)
{
List<RecordFeaturesMetaData> levels = new ArrayList<>();
List<String> apps = new ArrayList<>();
levels.add(RecordFeaturesMetaData.of(instance));
if(instance.getApps() != null)
{
for(var app : instance.getApps().values())
{
if(app.getSections() != null && app.getSections().stream().anyMatch(section -> section.getTables() != null && section.getTables().contains(table.getName())))
{
levels.add(RecordFeaturesMetaData.of(app));
apps.add(app.getName());
}
}
}
levels.add(RecordFeaturesMetaData.of(table));
boolean enabled = false;
for(var level : levels)
{
if(level != null)
{
if(Boolean.FALSE.equals(level.getFeatureLocks().get(feature)))
{
return (false);
}
enabled = level.getEnabled().getOrDefault(feature, enabled);
}
}
for(var scope : RecordFeatureOverride.ScopeType.values())
{
List<String> scopes = scope == RecordFeatureOverride.ScopeType.APP ? apps : java.util.Collections.singletonList(scope == RecordFeatureOverride.ScopeType.TABLE ? table.getName() : null);
for(String scopeName : scopes)
{
for(var override : overrides)
{
if(override.getFeature() == feature && override.getScopeType() == scope && override.getEnabled() != null
&& (scope == RecordFeatureOverride.ScopeType.INSTANCE || (scope == RecordFeatureOverride.ScopeType.APP && scopeName.equals(override.getScopeName()))
|| (scope == RecordFeatureOverride.ScopeType.TABLE && table.getName().equals(override.getScopeName()))))
{
enabled = override.getEnabled();
}
}
}
}
return (enabled);
}
}
@@ -53,7 +53,8 @@ public final class CollaborationApi
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0")
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline and people")
.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)
.withSpec(StarFavoriteSpec.class)
.withSpec(UnstarFavoriteSpec.class)
@@ -0,0 +1,125 @@
/*
* 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.api;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Versioned presence requests, polling snapshots, and realtime event data.
*******************************************************************************/
public final class CollaborationPresenceSchemas
{
/*******************************************************************************
** Schema namespace.
*******************************************************************************/
private CollaborationPresenceSchemas()
{
}
/*******************************************************************************
** All identity information is derived from the authenticated session.
*******************************************************************************/
public record CollaborationPresenceRequestV1(String connectionId, String table, String pk, String mode, String fieldName) implements ToSchema
{
/*******************************************************************************
** Clients renew every twenty seconds; the lease expires after forty-five.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Renew a 45-second presence lease; send every 20 seconds.")
.withProperties(new TreeMap<>(Map.of(
"connectionId", text("Client connection identity.", false), "table", text("Parent table name.", false),
"pk", text("Optional parent key; omit for a table view.", true),
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")),
"fieldName", text("Optional focused field on a record.", true)))));
}
}
/*******************************************************************************
** Leave removes only this authenticated user's connection and target.
*******************************************************************************/
public record CollaborationPresenceLeaveV1(String connectionId, String table, String pk) implements ToSchema
{
/*******************************************************************************
** Leave does not need a mode or a field.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Remove the caller's lease on this connection and target.")
.withProperties(new TreeMap<>(Map.of(
"connectionId", text("Client connection identity.", false), "table", text("Parent table name.", false),
"pk", text("Optional parent key.", true)))));
}
}
/*******************************************************************************
** The snapshot is also the data shape of presence.changed events.
*******************************************************************************/
public record CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers) implements ToSchema
{
/*******************************************************************************
** A table snapshot deliberately excludes record ids and focused field names.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Live viewers, merged across tabs; presence.changed uses this payload.")
.withProperties(new TreeMap<>(Map.of(
"table", text("Parent table name.", false), "pk", text("Record key; absent for table events.", true),
"viewers", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
}
}
/*******************************************************************************
** Only public identity and focus are carried, never field contents.
*******************************************************************************/
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName) implements ToSchema
{
/*******************************************************************************
** Editing takes precedence when one user has several open tabs.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs; EDITING takes precedence.")
.withProperties(new TreeMap<>(Map.of(
"userId", text("Stable user identity.", false), "displayName", text("Session display name.", false),
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true)))));
}
}
/*******************************************************************************
** Reuse scalar schema descriptions consistently.
*******************************************************************************/
private static Schema text(String description, boolean nullable)
{
return (new Schema().withType(Type.STRING).withDescription(description).withNullable(nullable));
}
}
@@ -0,0 +1,82 @@
/*
* 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.api;
import java.util.List;
import com.kingsrook.qbits.collaboration.presence.PresenceService;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Leave presence with fresh parent access checks.
*******************************************************************************/
public class DeletePresenceSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the extension operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.DELETE, "/presence", "Leave presence"));
}
/*******************************************************************************
** Explicit query parameters for polling.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationMutationResultV1");
}
/*******************************************************************************
** Declare a typed JSON request body.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("CollaborationPresenceLeaveV1"));
}
/*******************************************************************************
** Services derive identity from QContext and enforce visibility.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceLeaveV1.class);
new PresenceService().leave(body.connectionId(), required(body.table(), "table"), body.pk());
return (success());
}
}
@@ -0,0 +1,73 @@
/*
* 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.api;
import java.util.List;
import com.kingsrook.qbits.collaboration.presence.PresenceService;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Get presence snapshot with fresh parent access checks.
*******************************************************************************/
public class GetPresenceSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the extension operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.GET, "/presence", "Get presence snapshot"));
}
/*******************************************************************************
** Explicit query parameters for polling.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters("table!", "pk"));
}
/*******************************************************************************
** Name the versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationPresenceSnapshotV1");
}
/*******************************************************************************
** Services derive identity from QContext and enforce visibility.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String table = required(input.getQueryParam("table"), "table");
String pk = input.getQueryParam("pk");
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, new PresenceService().snapshot(table, pk)));
}
}
@@ -0,0 +1,82 @@
/*
* 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.api;
import java.util.List;
import com.kingsrook.qbits.collaboration.presence.PresenceService;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Renew presence with fresh parent access checks.
*******************************************************************************/
public class PostPresenceSpec extends CollaborationJsonSpec
{
/*******************************************************************************
** Describe the extension operation.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (operation(HttpMethod.POST, "/presence", "Renew presence"));
}
/*******************************************************************************
** Explicit query parameters for polling.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters());
}
/*******************************************************************************
** Name the versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("CollaborationPresenceSnapshotV1");
}
/*******************************************************************************
** Declare a typed JSON request body.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("CollaborationPresenceRequestV1"));
}
/*******************************************************************************
** Services derive identity from QContext and enforce visibility.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
var body = body(input, CollaborationPresenceSchemas.CollaborationPresenceRequestV1.class);
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(body.table(), body.pk(),
new PresenceService().heartbeat(body.connectionId(), required(body.table(), "table"), body.pk(), body.mode(), body.fieldName())));
}
}
@@ -83,7 +83,7 @@ public final class CollaborationHome
.withPermissionRules(CollaborationPermissions.permissionRules())
.withWidgets(List.of(HOME_WIDGET))
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
app.setParentAppName(config.getParentAppName());
return (app);
}
@@ -142,6 +142,7 @@ public final class CollaborationHome
personalSummary(html, CollaborationTables.RECENT, "Recently viewed");
attachmentSummary(html);
tagSummary(html);
fieldSummary(html);
html.append("<section><h3>Open access requests</h3><p>").append(count(CollaborationTables.ACCESS_REQUEST,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
html.append("<section><h3>Open tasks</h3><p>").append(count(CollaborationTables.TASK,
@@ -241,6 +242,35 @@ public final class CollaborationHome
/*******************************************************************************
** Real definition status and index totals, plus ephemeral admin-only counts.
*******************************************************************************/
private static void fieldSummary(StringBuilder html) throws QException
{
html.append("<section><h3>Custom fields</h3><ul>");
for(String status : List.of("DRAFT", "ACTIVE", "ARCHIVED"))
{
html.append("<li>").append(status).append(": ").append(count(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, status))).append("</li>");
}
for(String state : List.of("NONE", "BUILDING", "READY", "FAILED"))
{
html.append("<li>Index ").append(state).append(": ").append(count(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("indexState", QCriteriaOperator.EQUALS, state))).append("</li>");
}
html.append("</ul><p>Definitions with storage or index messages: ").append(count(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("statusMessage", QCriteriaOperator.IS_NOT_BLANK))).append(". See Custom Fields for details.</p></section>");
if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
{
html.append("<section><h3>Who's here</h3><ul>");
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, count) ->
html.append("<li>").append(table.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")).append(": ").append(count).append("</li>"));
html.append("</ul></section>");
}
}
/*******************************************************************************
** Widget renderer instantiated by QQQ.
*******************************************************************************/
@@ -0,0 +1,202 @@
/*
* 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.customfields;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
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.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
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.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** PostgreSQL indexes use independent autocommit connections, never a row transaction.
*******************************************************************************/
public class BuildFieldIndexes implements BackendStep
{
public static final String NAME = "collaborationBuildFieldIndexes";
/*******************************************************************************
** Always callable as a hidden job; scheduling is explicitly configured by the host.
*******************************************************************************/
public static QProcessMetaData metadata(CollaborationQBitConfig config)
{
var process = new QProcessMetaData().withName(NAME).withLabel("Build custom field indexes").withIsHidden(true)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(FieldSecurity.class)))
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(BuildFieldIndexes.class)));
if(config.getSchedulerName() != null)
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(60).withInitialDelaySeconds(60));
}
return (process);
}
/*******************************************************************************
** Retry pending and failed indexes, leaving completed indexes alone.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
FieldSecurity.requireManage();
for(QRecord definition : CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, List.of("ACTIVE", "ARCHIVED"))
.withCriteria("indexState", QCriteriaOperator.IN, List.of("NONE", "FAILED", "BUILDING"))))
{
build(definition);
}
}
/*******************************************************************************
** The published definition must commit before its index can be built.
*******************************************************************************/
public static void afterCommit(QRecord definition) throws QException
{
if(QContext.getQBackendTransaction() == null)
{
build(definition);
return;
}
var context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
var id = definition.getValue("id");
QContext.getQBackendTransaction().addAfterCommitCallback(() ->
{
try
{
QContext.withTemporaryContext(context, () -> build(FieldDefinitions.get(id)));
}
catch(QException e)
{
throw (new IllegalStateException("Could not build custom field index", e));
}
});
}
/*******************************************************************************
** Record the statement even when the host reserves DDL for migrations.
*******************************************************************************/
public static void build(QRecord definition) throws QException
{
FieldSecurity.requireManage();
QTableMetaData table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
if(!CustomFieldStorage.postgres(table))
{
return;
}
boolean managed = Boolean.TRUE.equals(CollaborationRecords.config().getManagedIndexes());
String ddl = ddl(table, definition, false);
if(ddl == null)
{
state(definition, "NONE", null, null);
return;
}
if(!managed)
{
state(definition, "NONE", "Apply indexDdl through the host migration tooling. Text indexes require pg_trgm for substring search.", ddl);
return;
}
try(var connection = CustomFieldStorage.connection(table))
{
connection.setAutoCommit(true);
boolean trigram;
try(var statement = connection.createStatement(); var result = statement.executeQuery("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')"))
{
result.next();
trigram = result.getBoolean(1);
}
ddl = ddl(table, definition, trigram);
state(definition, "BUILDING", null, ddl);
try(var statement = connection.createStatement())
{
statement.execute(ddl);
}
state(definition, "ARCHIVED".equals(definition.getValueString("status")) ? "NONE" : "READY", null, ddl);
}
catch(Exception e)
{
state(definition, "FAILED", e.getMessage(), ddl);
}
}
/*******************************************************************************
** Generate deterministic safe identifiers; PostgreSQL names are at most 63 bytes.
*******************************************************************************/
public static String ddl(QTableMetaData table, QRecord definition, boolean trigram) throws QException
{
String key = definition.getValueString("name");
if(key == null || !key.matches("[a-z][a-z0-9_]{0,40}"))
{
throw (new QException("Invalid field name"));
}
String index = "cf_" + table.getName() + "_" + key;
if(index.length() > 63)
{
index = index.substring(0, 45) + "_" + Integer.toUnsignedString(index.hashCode(), 16);
}
if("ARCHIVED".equals(definition.getValueString("status")))
{
return ("DROP INDEX CONCURRENTLY IF EXISTS " + CustomFieldStorage.identifier(index));
}
String type = switch(definition.getValueString("type"))
{
case "INTEGER" -> "integer";
case "DECIMAL" -> "numeric";
case "DATE" -> "date";
case "DATE_TIME" -> "timestamptz";
default -> null;
};
boolean searchable = Boolean.TRUE.equals(definition.getValueBoolean("searchable"));
if(!searchable && !(Boolean.TRUE.equals(definition.getValueBoolean("filterable")) && type != null))
{
return (null);
}
String expression = "(qqq_custom_fields->>'" + key + "')";
String prefix = "CREATE INDEX CONCURRENTLY IF NOT EXISTS " + CustomFieldStorage.identifier(index) + " ON " + CustomFieldStorage.sqlTable(table);
if(type == null && searchable && trigram)
{
return (prefix + " USING gin (" + expression + " gin_trgm_ops)");
}
return (prefix + " ((" + expression + (type == null ? "" : "::" + type) + "))");
}
/*******************************************************************************
** Internal diagnostic updates do not recursively refresh the overlay.
*******************************************************************************/
private static void state(QRecord definition, String state, String message, String ddl) throws QException
{
CollaborationRecords.update(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("id", definition.getValue("id"))
.withValue("indexState", state).withValue("statusMessage", message).withValue("indexDdl", ddl));
}
}
@@ -0,0 +1,140 @@
/*
* 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.customfields;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import java.util.WeakHashMap;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
/*******************************************************************************
** Probe once per instance/table; metadata refresh never performs schema DDL.
*******************************************************************************/
public final class CustomFieldStorage
{
private static final Map<QInstance, Map<String, Availability>> CACHE = new WeakHashMap<>();
/*******************************************************************************
** Static backend boundary.
*******************************************************************************/
private CustomFieldStorage()
{
}
/*******************************************************************************
** A visible reason accompanies every fallback.
*******************************************************************************/
public record Availability(boolean json, String reason)
{
}
/*******************************************************************************
** Memory needs no schema. Only PostgreSQL implements the JSON SQL contract.
*******************************************************************************/
public static synchronized Availability availability(QTableMetaData table)
{
var instance = QContext.getQInstance();
return (CACHE.computeIfAbsent(instance, key -> new HashMap<>()).computeIfAbsent(table.getName(), key -> probe(table)));
}
/*******************************************************************************
** Hosts can retry after applying the one-time migration.
*******************************************************************************/
public static synchronized void invalidate(QInstance instance, String table)
{
CACHE.getOrDefault(instance, new HashMap<>()).remove(table);
}
/*******************************************************************************
** Failed probes never install JSON fields that would break ordinary queries.
*******************************************************************************/
private static Availability probe(QTableMetaData table)
{
var backend = QContext.getQInstance().getBackend(table.getBackendName());
if(new MemoryBackendModule().getBackendType().equals(backend.getBackendType()))
{
return (new Availability(true, null));
}
if(!postgres(table))
{
return (new Availability(false, "Backend does not support JsonPathStorage; EAV fields are read-only filters (values are not selected)."));
}
try(Connection connection = connection(table); var statement = connection.createStatement())
{
statement.executeQuery("SELECT qqq_custom_fields FROM " + sqlTable(table) + " LIMIT 0").close();
return (new Availability(true, null));
}
catch(SQLException | IllegalArgumentException e)
{
return (new Availability(false, "JSON column unavailable; EAV fields are read-only filters. " + e.getMessage()));
}
}
/*******************************************************************************
** Match the installed RDBMS metadata, not a backend's arbitrary configured name.
*******************************************************************************/
public static boolean postgres(QTableMetaData table)
{
return (QContext.getQInstance().getBackend(table.getBackendName()) instanceof RDBMSBackendMetaData backend
&& ("postgres".equalsIgnoreCase(backend.getVendor()) || "postgresql".equalsIgnoreCase(backend.getVendor())));
}
/*******************************************************************************
** A fresh backend connection is intentionally outside QContext's transaction.
*******************************************************************************/
public static Connection connection(QTableMetaData table) throws SQLException
{
return (ConnectionManager.getConnection((RDBMSBackendMetaData) QContext.getQInstance().getBackend(table.getBackendName())));
}
/*******************************************************************************
** Quote metadata identifiers; never accept SQL fragments from definitions.
*******************************************************************************/
public static String identifier(String name)
{
if(name == null || name.isBlank() || name.indexOf('\0') >= 0)
{
throw (new IllegalArgumentException("Invalid SQL identifier"));
}
return ("\"" + name.replace("\"", "\"\"") + "\"");
}
/*******************************************************************************
** Backend table names may include a schema qualification.
*******************************************************************************/
public static String sqlTable(QTableMetaData table)
{
String name = table.getBackendDetails() instanceof com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails details ? details.getTableName() : null;
if(name == null)
{
name = table.getName();
}
return (java.util.Arrays.stream(name.split("\\.")).map(CustomFieldStorage::identifier).collect(java.util.stream.Collectors.joining(".")));
}
}
@@ -0,0 +1,68 @@
/*
* 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.customfields;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.collaboration.FeatureSettingsProvider;
import com.kingsrook.qqq.backend.core.actions.values.BasicCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Picker restricted to tables enabled for the custom-field designer.
*******************************************************************************/
public class CustomFieldTablesProvider extends BasicCustomPossibleValueProvider<QTableMetaData, String>
{
/*******************************************************************************
** Resolve code and persisted policy consistently with the overlay.
*******************************************************************************/
@Override
protected List<QTableMetaData> getAllSourceObjects() throws QException
{
var instance = QContext.getQInstance();
var overrides = new FeatureSettingsProvider().getOverrides(instance);
return (instance.getTables().values().stream().filter(table -> !table.getName().startsWith("collaboration_") && table.getPrimaryKeyField() != null
&& FeatureSettingsProvider.enabled(instance, table, RecordFeature.CUSTOM_FIELDS, overrides)).toList());
}
/*******************************************************************************
** Reject stale selections when the feature is disabled.
*******************************************************************************/
@Override
protected QTableMetaData getSourceObject(Serializable id) throws QException
{
return (getAllSourceObjects().stream().filter(table -> table.getName().equals(id)).findFirst().orElse(null));
}
/*******************************************************************************
** Use the metadata label rather than the SQL name.
*******************************************************************************/
@Override
protected QPossibleValue<String> makePossibleValue(QTableMetaData table)
{
return (new QPossibleValue<>(table.getName(), table.getLabel()));
}
}
@@ -0,0 +1,163 @@
/*
* 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.customfields;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.FeatureSettingsProvider;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataProviderInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
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.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RuntimeMetaDataContribution;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RuntimeTableMetaDataContribution;
import com.kingsrook.qqq.backend.core.model.metadata.fields.JsonPathStorage;
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.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueRangeBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
/*******************************************************************************
** ACTIVE definitions become normal fields through the core copy-on-write overlay.
*******************************************************************************/
public class CustomFieldsProvider implements RuntimeMetaDataProviderInterface
{
/*******************************************************************************
** Build against incoming feature settings, not the previous resolver snapshot.
*******************************************************************************/
@Override
public RuntimeMetaDataContribution getContribution(QInstance instance) throws QException
{
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.get(instance);
var result = new RuntimeMetaDataContribution();
var overrides = new FeatureSettingsProvider().getOverrides(instance);
List<QRecord> definitions = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION,
new QQueryFilter().withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")));
long generation = 1;
for(QRecord definition : definitions)
{
generation = 31 * generation + definition.getValues().hashCode();
var table = instance.getTable(definition.getValueString("tableName"));
if(!"ACTIVE".equals(definition.getValueString("status")) || table == null
|| !FeatureSettingsProvider.enabled(instance, table, RecordFeature.CUSTOM_FIELDS, overrides))
{
continue;
}
var storage = CustomFieldStorage.availability(table);
String storageName = storage.json() ? "JSONB" : "EAV";
if(!storageName.equals(definition.getValueString("storage")) || (!storage.json() && !java.util.Objects.equals(storage.reason(), definition.getValueString("statusMessage"))))
{
CollaborationRecords.update(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("id", definition.getValue("id"))
.withValue("storage", storageName).withValue("statusMessage", storage.reason()));
}
QFieldMetaData field = field(instance, definition, storage.json());
var contribution = result.getTables().computeIfAbsent(table.getName(), key -> new RuntimeTableMetaDataContribution());
if(storage.json())
{
contribution.getFields().add(field);
contribution.getSectionPlacements().put(field.getName(), definition.getValueString("sectionName") == null || definition.getValueString("sectionName").isBlank() ? "customFields" : definition.getValueString("sectionName"));
}
else
{
contribution.getVirtualFields().add((QVirtualFieldMetaData) field);
}
if(Boolean.TRUE.equals(definition.getValueBoolean("searchable")))
{
contribution.getSearchFields().add(field.getName());
}
}
return (result.withGeneration(generation & Long.MAX_VALUE));
}
/*******************************************************************************
** Core supports scalar STRING storage for multi-select, not array operators.
*******************************************************************************/
public static QFieldType type(String type)
{
return (switch(type)
{
case "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK" -> QFieldType.STRING;
default -> QFieldType.valueOf(type);
});
}
/*******************************************************************************
** Preserve classifications and supported validation on the contributed field.
*******************************************************************************/
private QFieldMetaData field(QInstance instance, QRecord definition, boolean json) throws QException
{
String name = "cf_" + definition.getValueString("name");
QFieldType type = type(definition.getValueString("type"));
QFieldMetaData field = json ? new QFieldMetaData(name, type).withBackendStorage(new JsonPathStorage().withColumnName("qqq_custom_fields").withKey(definition.getValueString("name")))
: new QVirtualFieldMetaData(name, type).withIsEditable(false).withIsQueryCriteria(true).withIsQuerySelectable(false)
.withCriteriaExpander(new QCodeReference(FieldCriteriaExpander.class));
field.withLabel(definition.getValueString("label")).withIsRequired(json && Boolean.TRUE.equals(definition.getValueBoolean("required")))
.withMaxLength(definition.getValueInteger("maxLength"));
if(definition.getValue("defaultValue") != null && json)
{
field.withDefaultValue(com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(type, definition.getValue("defaultValue")));
}
if(definition.getValueString("helpText") != null)
{
field.withHelpContent(new QHelpContent().withContentAsText(definition.getValueString("helpText")));
}
if((type == QFieldType.INTEGER || type == QFieldType.DECIMAL) && (definition.getValue("minValue") != null || definition.getValue("maxValue") != null))
{
field.withBehavior(new ValueRangeBehavior().withMinValue(definition.getValueBigDecimal("minValue")).withMaxValue(definition.getValueBigDecimal("maxValue")));
}
if(definition.getValueString("classification") != null)
{
field.withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.valueOf(definition.getValueString("classification"))));
}
switch(definition.getValueString("type"))
{
case "SELECT", "MULTI_SELECT" ->
{
String source = "collaboration_field_options_" + definition.getValueInteger("id");
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")))
{
field.withHelpContent(new QHelpContent().withContentAsText("Stored as a JSON-array STRING. Array membership operators and multi-select editing are not supported by core yet."));
}
}
case "USER" -> field.withPossibleValueSourceName(CollaborationTables.USER);
case "RECORD_LINK" -> field.withPossibleValueSourceName(definition.getValueString("linkTableName"));
default ->
{
}
}
return (field);
}
}
@@ -0,0 +1,110 @@
/*
* 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.customfields;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.tables.query.CriteriaExpansionContext;
import com.kingsrook.qqq.backend.core.actions.tables.query.VirtualFieldCriteriaExpanderInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QSubQuery;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Read-only EAV filters retain the parent's normal query permissions and paging.
*******************************************************************************/
public class FieldCriteriaExpander implements VirtualFieldCriteriaExpanderInterface
{
/*******************************************************************************
** Missing EAV rows count as blank; negative comparisons complement membership.
*******************************************************************************/
@Override
public QQueryFilter expand(QTableMetaData table, QFilterCriteria criteria, CriteriaExpansionContext context) throws QException
{
String name = criteria.getFieldName().substring(3);
var definitions = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter()
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("name", QCriteriaOperator.EQUALS, name)
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
if(definitions.size() != 1)
{
throw (new QException("Custom field is unavailable"));
}
var operator = criteria.getOperator();
boolean negate = operator == QCriteriaOperator.IS_BLANK || operator == QCriteriaOperator.NOT_EQUALS || operator == QCriteriaOperator.NOT_IN;
var positive = switch(operator)
{
case IS_BLANK -> QCriteriaOperator.IS_NOT_BLANK;
case NOT_EQUALS -> QCriteriaOperator.EQUALS;
case NOT_IN -> QCriteriaOperator.IN;
default -> operator;
};
String column = valueColumn(definitions.getFirst().getValueString("type"));
var valueType = context.instance().getTable(CollaborationTables.FIELD_VALUE).getField(column).getType();
var typedValues = criteria.getValues().stream().map(value -> com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(valueType, value)).toList();
QQueryFilter values = new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName())
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false).withCriteria("fieldName", QCriteriaOperator.EQUALS, name)
.withCriteria(new QFilterCriteria(column, positive, typedValues));
return (new QQueryFilter().withCriteria(table.getPrimaryKeyField(), negate ? QCriteriaOperator.NOT_IN : QCriteriaOperator.IN,
new QSubQuery(CollaborationTables.FIELD_VALUE, "recordId", values)));
}
/*******************************************************************************
** Shared typed column mapping for filtering and value counts.
*******************************************************************************/
public static String valueColumn(String type)
{
return (switch(type)
{
case "INTEGER", "DECIMAL" -> "valueNumber";
case "DATE" -> "valueDate";
case "DATE_TIME" -> "valueDateTime";
case "BOOLEAN" -> "valueBoolean";
default -> "valueText";
});
}
/*******************************************************************************
** Core's search callback has no field argument, so combine searchable EAV text.
*******************************************************************************/
@Override
public QQueryFilter expandSearchTerm(QTableMetaData table, String term, com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind kind, CriteriaExpansionContext context) throws QException
{
QQueryFilter result = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR);
for(var definition : CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter()
.withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")
.withCriteria("searchable", QCriteriaOperator.EQUALS, true)))
{
if("valueText".equals(valueColumn(definition.getValueString("type"))))
{
result.withSubFilter(expand(table, new QFilterCriteria("cf_" + definition.getValueString("name"), QCriteriaOperator.CONTAINS, term), context));
}
}
if(result.getSubFilters() == null || result.getSubFilters().isEmpty())
{
result.withCriteria(table.getPrimaryKeyField(), QCriteriaOperator.IN, java.util.List.of());
}
return (result);
}
}
@@ -0,0 +1,99 @@
/*
* 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.customfields;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.FeatureSettingsCustomizer;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
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.data.QRecord;
/*******************************************************************************
** Direct table writes enforce the same designer rules; post hooks refresh metadata.
*******************************************************************************/
public class FieldDefinitionCustomizer extends FeatureSettingsCustomizer
{
/*******************************************************************************
** New definitions always begin as drafts with server-managed lifecycle fields.
*******************************************************************************/
@Override
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean preview) throws QException
{
FieldSecurity.requireManage();
for(QRecord row : records)
{
row.withValue("status", "DRAFT").withValue("indexState", "NONE").withValue("storage", null)
.withValue("indexDdl", null).withValue("statusMessage", null).withValue("createdByUserId", PersonalRecords.currentUserId());
FieldDefinitions.validate(row, null);
}
return (records);
}
/*******************************************************************************
** Merge partial updates before validation and protect derived state from clients.
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
{
FieldSecurity.requireManage();
for(QRecord row : records)
{
QRecord old = FieldDefinitions.get(row.getValue("id"));
QRecord merged = new QRecord(old);
merged.getValues().putAll(row.getValues());
for(String field : List.of("status", "indexState", "storage", "indexDdl", "statusMessage", "createdByUserId"))
{
if(row.getValues().containsKey(field) && !java.util.Objects.equals(row.getValue(field), old.getValue(field)))
{
throw (new QException("Use the field actions to change " + field));
}
}
FieldDefinitions.validate(merged, old);
if(!java.util.Objects.equals(merged.getValue("statusMessage"), old.getValue("statusMessage")))
{
row.setValue("statusMessage", merged.getValue("statusMessage"));
}
}
return (records);
}
/*******************************************************************************
** Published field values must remain addressable; archive instead of deleting.
*******************************************************************************/
@Override
public List<QRecord> preDelete(DeleteInput input, List<QRecord> records, boolean preview) throws QException
{
FieldSecurity.requireManage();
for(QRecord row : records)
{
if(!"DRAFT".equals(FieldDefinitions.get(row.getValue("id")).getValueString("status")))
{
throw (new QException("Only draft definitions can be deleted; archive published fields."));
}
}
return (records);
}
}
@@ -0,0 +1,280 @@
/*
* 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.customfields;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.FeatureSettingsCustomizer;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
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.fields.JsonPathStorage;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
/*******************************************************************************
** Designer lifecycle and validations shared by direct writes and record actions.
*******************************************************************************/
public final class FieldDefinitions
{
/*******************************************************************************
** Static domain operations.
*******************************************************************************/
private FieldDefinitions()
{
}
/*******************************************************************************
** Managers may address definitions independently of host record read access.
*******************************************************************************/
public static QRecord get(Serializable id) throws QException
{
FieldSecurity.requireManage();
var rows = CollaborationRecords.query(CollaborationTables.FIELD_DEFINITION, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id));
if(rows.isEmpty())
{
throw (new QNotFoundException("Not found"));
}
return (rows.getFirst());
}
/*******************************************************************************
** Stable option ids survive label changes. JSON is [{"id":"a","label":"A"}].
*******************************************************************************/
public static Map<String, QPossibleValue<?>> options(QRecord definition) throws QException
{
Map<String, QPossibleValue<?>> result = new LinkedHashMap<>();
String json = definition.getValueString("options");
if(json == null || json.isBlank())
{
return (result);
}
try
{
var array = new ObjectMapper().readTree(json);
if(!array.isArray())
{
throw (new IllegalArgumentException("Expected an array"));
}
for(var option : array)
{
if(!option.path("id").isTextual() || option.path("id").asText().isBlank() || !option.path("label").isTextual()
|| result.putIfAbsent(option.path("id").asText(), new QPossibleValue<>(option.path("id").asText(), option.path("label").asText())) != null)
{
throw (new IllegalArgumentException("Options need unique nonblank string ids and labels"));
}
}
return (result);
}
catch(Exception e)
{
throw (new QException("Invalid field options: expected an array of {id, label} objects", e));
}
}
/*******************************************************************************
** Reject unsafe names, invalid types, missing links and immutable identity edits.
*******************************************************************************/
public static void validate(QRecord definition, QRecord old) throws QException
{
String name = definition.getValueString("name");
var table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
if(name == null || !name.matches("[a-z][a-z0-9_]{0,40}") || table == null || table.getName().startsWith("collaboration_") || table.getPrimaryKeyField() == null)
{
throw (new QException("Choose a host table and a name matching [a-z][a-z0-9_]{0,40}."));
}
if(definition.getValueString("type") == null || !List.of("STRING", "TEXT", "INTEGER", "DECIMAL", "BOOLEAN", "DATE", "DATE_TIME", "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK").contains(definition.getValueString("type")))
{
throw (new QException("Unsupported custom field type"));
}
if(definition.getValueString("label") == null || definition.getValueString("label").isBlank())
{
throw (new QException("A field label is required"));
}
var existing = table.getFields().get("cf_" + name);
if(existing != null && (old == null || "DRAFT".equals(old.getValueString("status")) || !(existing.getBackendStorage() instanceof JsonPathStorage storage && name.equals(storage.getKey()))))
{
throw (new QException("The field name clashes with a code field"));
}
var virtual = table.getVirtualFields() == null ? null : table.getVirtualFields().get("cf_" + name);
if(virtual != null && (old == null || "DRAFT".equals(old.getValueString("status"))))
{
throw (new QException("The field name clashes with a code virtual field"));
}
if(old != null && (!Objects.equals(old.getValue("tableName"), definition.getValue("tableName")) || !Objects.equals(old.getValue("name"), definition.getValue("name"))))
{
throw (new QException("Table and name cannot change after creation"));
}
if(old != null && !"DRAFT".equals(old.getValueString("status")) && !Objects.equals(old.getValue("type"), definition.getValue("type")))
{
throw (new QException("Type changes are only allowed in DRAFT"));
}
String classification = definition.getValueString("classification");
if(classification != null && !List.of("PUBLIC", "CONFIDENTIAL", "PII", "PHI").contains(classification))
{
throw (new QException("Unknown classification"));
}
if(definition.getValueInteger("maxLength") != null && definition.getValueInteger("maxLength") < 1)
{
throw (new QException("Maximum length must be positive"));
}
if(definition.getValueBigDecimal("minValue") != null && definition.getValueBigDecimal("maxValue") != null
&& definition.getValueBigDecimal("minValue").compareTo(definition.getValueBigDecimal("maxValue")) > 0)
{
throw (new QException("Minimum exceeds maximum"));
}
if("RECORD_LINK".equals(definition.getValueString("type")) && QContext.getQInstance().getPossibleValueSource(definition.getValueString("linkTableName")) == null)
{
throw (new QException("The link table needs a possible value source"));
}
if(definition.getValue("defaultValue") != null)
{
try
{
com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(CustomFieldsProvider.type(definition.getValueString("type")), definition.getValue("defaultValue"));
}
catch(RuntimeException e)
{
throw (new QException("Invalid default value for this field type", e));
}
}
var choices = options(definition);
if(old != null && "ACTIVE".equals(old.getValueString("status")))
{
for(String removed : options(old).keySet())
{
if(!choices.containsKey(removed))
{
if(CustomFieldStorage.postgres(table) && CustomFieldStorage.availability(table).json())
{
if(optionUses(old, removed) > 0)
{
throw (new QException("Cannot remove an option that is still used: " + removed));
}
}
else
{
definition.setValue("statusMessage", "Warning: option removal usage checks are only available on PostgreSQL JSON storage.");
}
}
}
}
}
/*******************************************************************************
** Containment values are parameters, including the scalar JSON-array string.
*******************************************************************************/
private static long optionUses(QRecord definition, String option) throws QException
{
var table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
String predicate = "qqq_custom_fields @> ?::jsonb";
boolean multi = "MULTI_SELECT".equals(definition.getValueString("type"));
if(multi)
{
predicate = "(qqq_custom_fields->>?)::jsonb @> ?::jsonb";
}
try(var connection = CustomFieldStorage.connection(table); var statement = connection.prepareStatement("SELECT COUNT(*) FROM " + CustomFieldStorage.sqlTable(table) + " WHERE " + predicate))
{
if(multi)
{
statement.setString(1, definition.getValueString("name"));
statement.setString(2, new ObjectMapper().writeValueAsString(List.of(option)));
}
else
{
statement.setString(1, new ObjectMapper().writeValueAsString(Map.of(definition.getValueString("name"), option)));
}
try(var result = statement.executeQuery())
{
result.next();
return (result.getLong(1));
}
}
catch(Exception e)
{
throw (new QException("Cannot verify option usage", e));
}
}
/*******************************************************************************
** Count affected values for the Archive Review, without loading host records.
*******************************************************************************/
public static long valueCount(QRecord definition) throws QException
{
var table = QContext.getQInstance().getTable(definition.getValueString("tableName"));
String target = CustomFieldStorage.availability(table).json() ? table.getName() : CollaborationTables.FIELD_VALUE;
QQueryFilter filter = target.equals(table.getName()) ? new QQueryFilter().withCriteria("cf_" + definition.getValueString("name"), QCriteriaOperator.IS_NOT_BLANK)
: new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table.getName()).withCriteria("fieldName", QCriteriaOperator.EQUALS, definition.getValueString("name"))
.withCriteria(FieldCriteriaExpander.valueColumn(definition.getValueString("type")), QCriteriaOperator.IS_NOT_BLANK);
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput(target).withFilter(filter);
input.withPermissionBypass("Field manager counts affected values for Archive Review.");
return (new com.kingsrook.qqq.backend.core.actions.tables.CountAction().execute(input).getCount());
}
/*******************************************************************************
** Revalidate immediately before applying the action; refresh only after commit.
*******************************************************************************/
public static void apply(Serializable id, String operation) throws QException
{
QRecord definition = get(id);
review(definition, operation);
if(!"Rebuild".equals(operation))
{
String status = "Publish".equals(operation) ? "ACTIVE" : "ARCHIVED";
definition.setValue("status", status);
CollaborationRecords.update(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("id", id).withValue("status", status).withValue("indexState", "NONE"));
new FeatureSettingsCustomizer().postUpdate(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withTransaction(QContext.getQBackendTransaction()), List.of(), java.util.Optional.empty());
}
BuildFieldIndexes.afterCommit(definition);
}
/*******************************************************************************
** Review and Apply share precisely the same state transition rules.
*******************************************************************************/
public static void review(QRecord definition, String operation) throws QException
{
FieldSecurity.requireManage();
validate(definition, definition);
if(!List.of("Publish", "Archive", "Rebuild").contains(operation))
{
throw (new QException("Unknown field action"));
}
if("Publish".equals(operation) && new CustomFieldTablesProvider().getPossibleValue(definition.getValueString("tableName")) == null)
{
throw (new QException("CUSTOM_FIELDS must be enabled before publishing"));
}
String expected = "Publish".equals(operation) ? "DRAFT" : "ACTIVE";
if(!expected.equals(definition.getValueString("status")))
{
throw (new QException(operation + " requires " + expected));
}
}
}
@@ -0,0 +1,187 @@
/*
* 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.customfields;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.metadata.code.QCodeReference;
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.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.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Standard streamed Review/Result field actions, never navigation entries.
*******************************************************************************/
public final class FieldProcesses
{
/*******************************************************************************
** Static metadata factory.
*******************************************************************************/
private FieldProcesses()
{
}
/*******************************************************************************
** Bind the action operation into trusted process metadata.
*******************************************************************************/
public static List<QProcessMetaData> metadata()
{
List<QProcessMetaData> processes = new ArrayList<>();
for(String operation : List.of("Publish", "Archive", "Rebuild"))
{
var values = new HashMap<String, Serializable>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.FIELD_DEFINITION);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.FIELD_DEFINITION);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, operation + " selected custom fields.");
processes.add(StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values)
.withName("collaboration" + operation + "Field").withLabel(operation.equals("Rebuild") ? "Rebuild index" : operation + " field")
.withTableName(CollaborationTables.FIELD_DEFINITION)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(FieldSecurity.class))));
}
return (processes);
}
/*******************************************************************************
** Request parameters cannot substitute another lifecycle operation.
*******************************************************************************/
private static String operation(RunBackendStepInput input)
{
return (input.getProcess().getName().substring("collaboration".length()).replace("Field", ""));
}
/*******************************************************************************
** Summary language works for both singular and plural selections.
*******************************************************************************/
private static ProcessSummaryLine line(Status status, String message)
{
var line = new ProcessSummaryLine(status, 0, null);
line.setSingularFutureMessage(message);
line.setPluralFutureMessage(message);
line.setSingularPastMessage(message);
line.setPluralPastMessage(message);
return (line);
}
/*******************************************************************************
** Review validates every row and counts values retained by archive.
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Failed rows are omitted from the load stream with an explicit reason.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(var row : input.getRecords())
{
var id = row.getValue("id");
ProcessSummaryLine summary;
try
{
var definition = FieldDefinitions.get(id);
FieldDefinitions.review(definition, operation(input));
String message = operation(input) + " field " + definition.getValueString("label");
if("Archive".equals(operation(input)))
{
message += "; " + FieldDefinitions.valueCount(definition) + " records have a value (values retained)";
}
summary = line(Status.OK, message);
output.getRecords().add(row);
}
catch(QException e)
{
summary = line(Status.WARNING, "Skipped: " + e.getMessage());
}
summary.incrementCountAndAddPrimaryKey(id);
lines.add(summary);
}
}
/*******************************************************************************
** Streamed pages accumulate an explicit Review summary.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (lines);
}
}
/*******************************************************************************
** Apply rechecks permissions and status, then reports actual outcomes.
*******************************************************************************/
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** One failed field does not prevent other selected fields from completing.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(var row : input.getRecords())
{
ProcessSummaryLine summary;
try
{
FieldDefinitions.apply(row.getValue("id"), operation(input));
summary = line(Status.OK, operation(input) + " completed");
}
catch(QException e)
{
summary = line(Status.ERROR, "Failed: " + e.getMessage());
}
summary.incrementCountAndAddPrimaryKey(row.getValue("id"));
lines.add(summary);
}
}
/*******************************************************************************
** Report completed actions independently of Review predictions.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (lines);
}
}
}
@@ -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.customfields;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
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.tables.delete.DeleteInput;
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.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Definitions are readable with view and writable by field managers or admins.
*******************************************************************************/
public class FieldSecurity implements CustomPermissionChecker
{
/*******************************************************************************
** Processes and writes require management authority.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput || metadata instanceof QProcessMetaData)
{
requireManage();
return;
}
var session = QContext.getQSession();
if(session == null || (!session.hasPermission(CollaborationPermissions.VIEW) && !session.hasPermission(CollaborationPermissions.ADMIN)))
{
throw (new QPermissionDeniedException("This needs collaboration.view."));
}
}
/*******************************************************************************
** Services recheck permission, including at the load step.
*******************************************************************************/
public static void requireManage() throws QPermissionDeniedException
{
var session = QContext.getQSession();
if(!(session instanceof QSystemUserSession) && (session == null || (!session.hasPermission(CollaborationPermissions.FIELDS_MANAGE) && !session.hasPermission(CollaborationPermissions.ADMIN))))
{
throw (new QPermissionDeniedException("This needs collaboration.fields.manage."));
}
}
}
@@ -63,6 +63,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
*******************************************************************************/
public final class CollaborationTables
{
public static final String FIELD_DEFINITION = "collaboration_field_definition";
public static final String FIELD_VALUE = "collaboration_field_value";
public static final String SCOPE_NAME = "collaboration_scope_name";
public static final String RECORD_FIELD_NAME = "collaboration_record_field_name";
public static final String SHARE = "collaboration_share";
@@ -104,6 +106,16 @@ public final class CollaborationTables
public static List<QPossibleValueSource> possibleValueSources()
{
return (List.of(
QPossibleValueSource.newForTable(FIELD_DEFINITION), QPossibleValueSource.newForTable(FIELD_VALUE),
enumSource("collaboration_field_type", "STRING", "TEXT", "INTEGER", "DECIMAL", "BOOLEAN", "DATE", "DATE_TIME", "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK"),
enumSource("collaboration_field_status", "DRAFT", "ACTIVE", "ARCHIVED"),
enumSource("collaboration_field_index_state", "NONE", "BUILDING", "READY", "FAILED"),
enumSource("collaboration_field_classification", "PUBLIC", "CONFIDENTIAL", "PII", "PHI"),
new QPossibleValueSource().withName("collaboration_custom_field_tables").withType(QPossibleValueSourceType.CUSTOM).withIdType(QFieldType.STRING)
.withCustomCodeReference(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.CustomFieldTablesProvider.class)),
new QPossibleValueSource().withName(SCOPE_NAME).withType(QPossibleValueSourceType.CUSTOM)
.withCustomCodeReference(new QCodeReference(FeatureScopeNamePossibleValueProvider.class)),
new QPossibleValueSource().withName(RECORD_FIELD_NAME).withType(QPossibleValueSourceType.CUSTOM)
@@ -189,6 +201,7 @@ public final class CollaborationTables
tables.addAll(conversationTables(config));
tables.addAll(tagTables(config));
tables.addAll(workTables(config));
tables.addAll(customFieldTables(config));
for(QTableMetaData table : tables)
{
table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates"));
@@ -473,4 +486,63 @@ public final class CollaborationTables
}
return (tables);
}
/*******************************************************************************
** The designer is an ordinary audited table; fallback values are children.
*******************************************************************************/
private static List<QTableMetaData> customFieldTables(CollaborationQBitConfig config)
{
QTableMetaData definitions = base(config, FIELD_DEFINITION, "Custom Fields", "input")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.FieldSecurity.class)))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withRecordLabelFormatAndFields("%s: %s", "tableName", "label")
.withField(string("tableName", "Table", 128).withIsRequired(true).withPossibleValueSourceName("collaboration_custom_field_tables"))
.withField(string("name", "Name", 41).withIsRequired(true))
.withField(string("label", "Label", 255).withIsRequired(true))
.withField(string("type", "Type", 32).withIsRequired(true).withPossibleValueSourceName("collaboration_field_type"))
.withField(new QFieldMetaData("options", QFieldType.TEXT).withLabel("Options (JSON)"))
.withField(string("linkTableName", "Link table", 128))
.withField(new QFieldMetaData("required", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(new QFieldMetaData("defaultValue", QFieldType.TEXT))
.withField(new QFieldMetaData("minValue", QFieldType.DECIMAL))
.withField(new QFieldMetaData("maxValue", QFieldType.DECIMAL))
.withField(new QFieldMetaData("maxLength", QFieldType.INTEGER))
.withField(new QFieldMetaData("helpText", QFieldType.TEXT))
.withField(string("sectionName", "Section", 128))
.withField(new QFieldMetaData("sortOrder", QFieldType.INTEGER).withDefaultValue(0))
.withField(new QFieldMetaData("filterable", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(new QFieldMetaData("searchable", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(string("classification", "Classification", 32).withPossibleValueSourceName("collaboration_field_classification"))
.withField(string("status", "Status", 16).withDefaultValue("DRAFT").withIsRequired(true).withIsEditable(false).withPossibleValueSourceName("collaboration_field_status"))
.withField(string("indexState", "Index state", 16).withDefaultValue("NONE").withIsEditable(false).withPossibleValueSourceName("collaboration_field_index_state"))
.withField(string("storage", "Storage", 16).withIsEditable(false))
.withField(new QFieldMetaData("statusMessage", QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData("indexDdl", QFieldType.TEXT).withLabel("Index DDL").withIsEditable(false))
.withField(string("createdByUserId", "Created by", 255).withIsEditable(false))
.withUniqueKey(new UniqueKey("tableName", "name"));
QCodeReference customizer = new QCodeReference(com.kingsrook.qbits.collaboration.customfields.FieldDefinitionCustomizer.class);
definitions.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, customizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, customizer)
.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, customizer)
.withCustomizer(TableCustomizers.POST_INSERT_RECORD, customizer).withCustomizer(TableCustomizers.POST_UPDATE_RECORD, customizer)
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, customizer)
.withSection(new QFieldSection("definition", new QIcon("input"), Tier.T1,
List.of("id", "tableName", "name", "label", "type", "status", "classification")).withLabel("Definition"))
.withSection(new QFieldSection("settings", new QIcon("settings"), Tier.T2,
List.of("options", "linkTableName", "required", "defaultValue", "minValue", "maxValue", "maxLength", "helpText", "sectionName", "sortOrder", "filterable", "searchable")).withLabel("Settings"))
.withSection(new QFieldSection("storage", new QIcon("storage"), Tier.T2,
List.of("storage", "indexState", "statusMessage", "indexDdl", "createdByUserId")).withLabel("Storage and indexes"));
QTableMetaData values = referenced(config, FIELD_VALUE, "Custom Field Values", "input")
.withField(string("fieldName", "Field name", 41).withIsRequired(true))
.withField(new QFieldMetaData("valueText", QFieldType.TEXT))
.withField(new QFieldMetaData("valueNumber", QFieldType.DECIMAL))
.withField(new QFieldMetaData("valueDate", QFieldType.DATE))
.withField(new QFieldMetaData("valueDateTime", QFieldType.DATE_TIME))
.withField(new QFieldMetaData("valueBoolean", QFieldType.BOOLEAN))
.withUniqueKey(new UniqueKey("tableName", "recordId", "fieldName"))
.withSection(new QFieldSection("value", new QIcon("input"), Tier.T1,
List.of("id", "fieldName", "valueText", "valueNumber", "valueDate", "valueDateTime", "valueBoolean")).withLabel("Value"));
values.getFields().values().forEach(field -> field.setIsEditable(false));
return (List.of(definitions, values));
}
}
@@ -48,6 +48,12 @@ public final class ConversationChildren
*******************************************************************************/
public static void install(QInstance instance)
{
QJoinMetaData fieldJoin = new QJoinMetaData().withLeftTable(CollaborationTables.FIELD_DEFINITION).withRightTable(CollaborationTables.FIELD_VALUE)
.withInferredName().withType(JoinType.ONE_TO_MANY).withJoinOns(List.of(new JoinOn("tableName", "tableName"), new JoinOn("name", "fieldName")));
instance.addJoin(fieldJoin);
instance.addWidget(ChildRecordListRenderer.widgetMetaDataBuilder(fieldJoin).withLabel("Custom Field Values").withCanAddChildRecord(false).getWidgetMetaData());
instance.getTable(CollaborationTables.FIELD_DEFINITION).withSection(new QFieldSection("values", new QIcon("list"), Tier.T2)
.withLabel("Custom Field Values").withWidgetName(fieldJoin.getName()));
QJoinMetaData tagJoin = new QJoinMetaData().withLeftTable(CollaborationTables.TAG).withRightTable(CollaborationTables.RECORD_TAG)
.withInferredName().withType(JoinType.ONE_TO_MANY).withJoinOns(List.of(new JoinOn("id", "tagId")));
instance.addJoin(tagJoin);
@@ -0,0 +1,366 @@
/*
* 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.presence;
import java.io.Serializable;
import java.time.Clock;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.WeakHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
import com.kingsrook.qqq.backend.core.realtime.RealtimeBusInterface;
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
/*******************************************************************************
** Ephemeral per-pod presence replicated over a channel core refuses to clients.
** Publish outside the monitor: the in-memory bus waits for listener threads.
*******************************************************************************/
public final class PresenceRegistry implements AutoCloseable
{
public static final String INTERNAL_CHANNEL = "instance:presence";
public static final long TTL_MILLIS = 45000;
public static final int MAX_ENTRIES = 50000;
private static final QLogger LOG = QLogger.getLogger(PresenceRegistry.class);
private static final Map<QInstance, PresenceRegistry> REGISTRIES = new WeakHashMap<>();
private final Map<Key, Entry> entries = new HashMap<>();
private final RealtimeBusInterface bus;
private final Clock clock;
private final int capacity;
private final AutoCloseable subscription;
private ScheduledExecutorService expiry;
/*******************************************************************************
** Clock and cap injection makes expiry and overload deterministic in tests.
*******************************************************************************/
public PresenceRegistry(RealtimeBusInterface bus, Clock clock, int capacity)
{
this.bus = bus;
this.clock = clock;
this.capacity = capacity;
subscription = bus.subscribe(this::receive);
}
/*******************************************************************************
** Install on every pod, even before its first local heartbeat.
*******************************************************************************/
public static synchronized PresenceRegistry get(QInstance instance)
{
return (REGISTRIES.computeIfAbsent(instance, key ->
{
var registry = new PresenceRegistry(RealtimeBus.get(instance), Clock.systemUTC(), MAX_ENTRIES);
registry.expiry = Executors.newSingleThreadScheduledExecutor(runnable ->
{
Thread thread = new Thread(runnable, "collaboration-presence-expiry");
thread.setDaemon(true);
return (thread);
});
registry.expiry.scheduleWithFixedDelay(() ->
{
try
{
registry.expire();
}
catch(RuntimeException e)
{
LOG.warn("Could not expire presence", e);
}
}, 1, 1, TimeUnit.SECONDS);
return (registry);
}));
}
/*******************************************************************************
** Hosts stop the timer and unsubscribe before closing the instance realtime bus.
*******************************************************************************/
public static synchronized void close(QInstance instance)
{
var registry = REGISTRIES.remove(instance);
if(registry != null)
{
registry.close();
}
}
/*******************************************************************************
** Stable identity namespaces client connection ids by the authenticated user.
*******************************************************************************/
private record Key(String user, String connection, String table, String pk)
{
}
/*******************************************************************************
** Each tab has its own lease; user views merge the live leases.
*******************************************************************************/
private record Entry(String displayName, String mode, String fieldName, long at)
{
}
/*******************************************************************************
** Transport-independent snapshot entry, never a persisted directory row.
*******************************************************************************/
public record Viewer(String userId, String displayName, String mode, String fieldName) implements Serializable
{
}
/*******************************************************************************
** Refresh locally even if transport delivery fails; replicate every heartbeat.
*******************************************************************************/
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName)
{
expire();
var key = new Key(user, connection, table, pk);
var entry = new Entry(displayName, mode, fieldName, clock.millis());
if(!apply(key, entry, false))
{
return (false);
}
publish(key, entry, false);
return (true);
}
/*******************************************************************************
** Leaves use the same internal channel so every pod removes the lease.
*******************************************************************************/
public void leave(String user, String connection, String table, String pk)
{
var key = new Key(user, connection, table, pk);
var entry = new Entry(null, null, null, clock.millis());
apply(key, entry, true);
publish(key, entry, true);
}
/*******************************************************************************
** Internal payloads contain only identity and focus, never record values.
*******************************************************************************/
private void publish(Key key, Entry entry, boolean leave)
{
Map<String, Serializable> data = new LinkedHashMap<>();
data.put("userId", key.user());
data.put("connectionId", key.connection());
data.put("table", key.table());
data.put("pk", key.pk());
data.put("displayName", entry.displayName());
data.put("mode", entry.mode());
data.put("fieldName", entry.fieldName());
data.put("at", entry.at());
data.put("leave", leave);
bus.publish(new RealtimeMessage().withChannel(INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(data));
}
/*******************************************************************************
** Client-visible change events cannot enter the replication path.
*******************************************************************************/
private void receive(RealtimeMessage message)
{
if(!INTERNAL_CHANNEL.equals(message.getChannel()) || !"presence.heartbeat".equals(message.getEvent()))
{
return;
}
var data = message.getData();
if(!(data.get("at") instanceof Number at) || !(data.get("userId") instanceof String user)
|| !(data.get("connectionId") instanceof String connection) || !(data.get("table") instanceof String table))
{
return;
}
expire();
long receivedAt = Math.min(at.longValue(), clock.millis());
if(receivedAt + TTL_MILLIS <= clock.millis())
{
return;
}
apply(new Key(user, connection, table, (String) data.get("pk")),
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
}
/*******************************************************************************
** Unchanged renewals extend TTL without generating client event traffic.
*******************************************************************************/
private boolean apply(Key key, Entry entry, boolean leave)
{
boolean changed;
synchronized(this)
{
Entry old = entries.get(key);
if(old != null && old.at() > entry.at())
{
return (true);
}
if(!leave && old == null && entries.size() >= capacity)
{
LOG.warn("Presence capacity reached; refusing a new target");
return (false);
}
List<Viewer> before = snapshotInternal(key.table(), key.pk());
if(leave)
{
entries.remove(key);
}
else
{
entries.put(key, entry);
}
changed = !before.equals(snapshotInternal(key.table(), key.pk()));
}
if(changed)
{
changed(key.table(), key.pk());
}
return (true);
}
/*******************************************************************************
** Expiry is active even without a polling client; expired data is never returned.
*******************************************************************************/
public void expire()
{
List<Key> removed = new ArrayList<>();
synchronized(this)
{
entries.entrySet().removeIf(entry ->
{
if(entry.getValue().at() + TTL_MILLIS <= clock.millis())
{
removed.add(entry.getKey());
return (true);
}
return (false);
});
}
removed.stream().map(key -> new Key(null, null, key.table(), key.pk())).distinct().forEach(key -> changed(key.table(), key.pk()));
}
/*******************************************************************************
** Table snapshots omit record keys and focused fields to avoid lock disclosures.
*******************************************************************************/
public List<Viewer> snapshot(String table, String pk)
{
expire();
synchronized(this)
{
return (snapshotInternal(table, pk));
}
}
/*******************************************************************************
** Editing wins over viewing; within a mode the most recent tab supplies focus.
*******************************************************************************/
private List<Viewer> snapshotInternal(String table, String pk)
{
Map<String, Entry> users = new java.util.TreeMap<>();
for(var row : entries.entrySet())
{
if(row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis())
{
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
}
}
return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName())).toList());
}
/*******************************************************************************
** Only counts are exposed to the admin home, with no row contents or record ids.
*******************************************************************************/
public Map<String, Integer> counts()
{
expire();
synchronized(this)
{
Map<String, Integer> result = new java.util.TreeMap<>();
entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, snapshotInternal(table, null).size()));
return (result);
}
}
/*******************************************************************************
** Record focus stays on its record channel; table events contain table snapshots.
*******************************************************************************/
private void changed(String table, String pk)
{
if(pk != null)
{
emit("record:" + table + ":" + pk, table, pk);
}
emit("table:" + table, table, null);
}
/*******************************************************************************
** Maps keep the bus payload portable across in-memory and JSON ESB transports.
*******************************************************************************/
private void emit(String channel, String table, String pk)
{
Map<String, Serializable> data = new LinkedHashMap<>();
data.put("table", table);
if(pk != null)
{
data.put("pk", pk);
}
ArrayList<LinkedHashMap<String, Serializable>> viewers = new ArrayList<>();
synchronized(this)
{
for(Viewer viewer : snapshotInternal(table, pk))
{
LinkedHashMap<String, Serializable> value = new LinkedHashMap<>();
value.put("userId", viewer.userId());
value.put("displayName", viewer.displayName());
value.put("mode", viewer.mode());
value.put("fieldName", viewer.fieldName());
viewers.add(value);
}
}
data.put("viewers", viewers);
bus.publish(new RealtimeMessage().withChannel(channel).withEvent("presence.changed").withData(data));
}
/*******************************************************************************
** Release listeners and timer on host shutdown or in tests.
*******************************************************************************/
@Override
public void close()
{
if(expiry != null)
{
expiry.shutdownNow();
}
try
{
subscription.close();
}
catch(Exception e)
{
LOG.warn("Could not unsubscribe presence", e);
}
synchronized(this)
{
entries.clear();
}
}
}
@@ -0,0 +1,151 @@
/*
* 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.presence;
import java.util.List;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
/*******************************************************************************
** Authenticated, permission-checked boundary for presence; nothing is persisted.
*******************************************************************************/
public class PresenceService
{
/*******************************************************************************
** Recheck visibility on every renewal and remove a refused caller's old lease.
*******************************************************************************/
public List<PresenceRegistry.Viewer> heartbeat(String connection, String table, String pk, String mode, String field) throws QException
{
String user = PersonalRecords.currentUserId();
validateConnection(connection);
pk = canonicalKey(table, pk);
try
{
pk = authorize(table, pk);
}
catch(QException e)
{
registry().leave(user, connection, table, pk);
throw (e);
}
if(!List.of("VIEWING", "EDITING").contains(mode == null ? "" : mode))
{
throw (new QBadRequestException("Mode must be VIEWING or EDITING"));
}
if(field != null && (pk == null || QContext.getQInstance().getTable(table).getFields().get(field) == null))
{
throw (new QBadRequestException("Unknown focused field"));
}
String name = QContext.getQSession().getUser().getFullName();
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, table, pk, mode, field))
{
throw (new QBadRequestException("Presence capacity reached; retry later"));
}
return (registry().snapshot(table, pk));
}
/*******************************************************************************
** Namespacing by user prevents one user from removing another user's connection.
*******************************************************************************/
public void leave(String connection, String table, String pk) throws QException
{
String user = PersonalRecords.currentUserId();
validateConnection(connection);
String canonical = authorize(table, pk);
registry().leave(user, connection, table, canonical);
}
/*******************************************************************************
** Polling has the same access boundary as heartbeat and SSE record subscriptions.
*******************************************************************************/
public List<PresenceRegistry.Viewer> snapshot(String table, String pk) throws QException
{
PersonalRecords.currentUserId();
return (registry().snapshot(table, authorize(table, pk)));
}
/*******************************************************************************
** A refusal always has the same not-found result, including disabled features.
*******************************************************************************/
private String authorize(String table, String pk) throws QException
{
if(!ParentRecordAccess.canReadTable(table) || !RecordFeaturesResolver.resolve(QContext.getQInstance(), QContext.getQInstance().getTable(table)).isEnabled(RecordFeature.PRESENCE))
{
throw (new QNotFoundException("Not found"));
}
if(pk != null)
{
var record = ParentRecordAccess.requireVisibleParent(table, pk);
return (record.getValueString(QContext.getQInstance().getTable(table).getPrimaryKeyField()));
}
return (null);
}
/*******************************************************************************
** Canonicalize before the read check so a refused renewal removes an aliased key.
*******************************************************************************/
private String canonicalKey(String tableName, String pk) throws QNotFoundException
{
if(pk == null)
{
return (null);
}
try
{
var table = QContext.getQInstance().getTable(tableName);
if(table == null || table.getPrimaryKeyField() == null)
{
throw (new IllegalArgumentException("Unknown table"));
}
return (com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), pk).toString());
}
catch(RuntimeException e)
{
throw (new QNotFoundException("Not found"));
}
}
/*******************************************************************************
** Bound caller-controlled keys independently of the registry entry cap.
*******************************************************************************/
private void validateConnection(String connection) throws QBadRequestException
{
if(connection == null || connection.isBlank() || connection.length() > 255)
{
throw (new QBadRequestException("A connectionId of at most 255 characters is required"));
}
}
/*******************************************************************************
** All endpoints on a pod share the eagerly installed instance registry.
*******************************************************************************/
private PresenceRegistry registry()
{
return (PresenceRegistry.get(QContext.getQInstance()));
}
}
@@ -55,7 +55,7 @@ public class CollaborationRowSecurity implements CustomPermissionChecker
fields.addAll(java.util.List.of("id", "tableName", "recordId", "parentDeleted"));
query.setFieldNamesToInclude(fields);
}
if(!(metadata instanceof QTableMetaData table && table.getName().equals(CollaborationTables.RECORD_TAG)))
if(!(metadata instanceof QTableMetaData table && (table.getName().equals(CollaborationTables.RECORD_TAG) || table.getName().equals(CollaborationTables.FIELD_VALUE))))
{
new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata);
}
@@ -21,7 +21,6 @@
package com.kingsrook.qbits.collaboration.tags;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.collaboration.FeatureSettingsProvider;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
@@ -30,14 +29,11 @@ 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.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeatureOverride;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RuntimeMetaDataContribution;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RuntimeTableMetaDataContribution;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Contribute the Tags filter and quick-search field without changing host hooks.
@@ -56,7 +52,7 @@ public class TagsMetaDataProvider implements RuntimeMetaDataProviderInterface
{
if(!table.getName().startsWith("collaboration_") && table.getPrimaryKeyField() != null
&& (table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_GET) || table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_QUERY))
&& enabled(instance, table, overrides))
&& FeatureSettingsProvider.enabled(instance, table, RecordFeature.TAGS, overrides))
{
contribution.getTables().put(table.getName(), new RuntimeTableMetaDataContribution()
.withVirtualFields(List.of(new QVirtualFieldMetaData("collaborationTags", QFieldType.STRING).withLabel("Tags")
@@ -68,54 +64,4 @@ public class TagsMetaDataProvider implements RuntimeMetaDataProviderInterface
return (contribution);
}
/*******************************************************************************
** Resolve TAGS against the incoming policy in core's instance/app/table order.
*******************************************************************************/
private boolean enabled(QInstance instance, QTableMetaData table, List<RecordFeatureOverride> overrides)
{
List<RecordFeaturesMetaData> levels = new ArrayList<>();
List<String> apps = new ArrayList<>();
levels.add(RecordFeaturesMetaData.of(instance));
if(instance.getApps() != null)
{
for(var app : instance.getApps().values())
{
if(app.getSections() != null && app.getSections().stream().anyMatch(section -> section.getTables() != null && section.getTables().contains(table.getName())))
{
levels.add(RecordFeaturesMetaData.of(app));
apps.add(app.getName());
}
}
}
levels.add(RecordFeaturesMetaData.of(table));
boolean enabled = false;
for(var level : levels)
{
if(level != null)
{
if(Boolean.FALSE.equals(level.getFeatureLocks().get(RecordFeature.TAGS)))
{
return (false);
}
enabled = level.getEnabled().getOrDefault(RecordFeature.TAGS, enabled);
}
}
for(var scope : RecordFeatureOverride.ScopeType.values())
{
List<String> scopes = scope == RecordFeatureOverride.ScopeType.APP ? apps : java.util.Collections.singletonList(scope == RecordFeatureOverride.ScopeType.TABLE ? table.getName() : null);
for(String scopeName : scopes)
{
for(var override : overrides)
{
if(override.getFeature() == RecordFeature.TAGS && override.getScopeType() == scope && override.getEnabled() != null
&& (scope == RecordFeatureOverride.ScopeType.INSTANCE || (scope == RecordFeatureOverride.ScopeType.APP && scopeName.equals(override.getScopeName()))
|| (scope == RecordFeatureOverride.ScopeType.TABLE && table.getName().equals(override.getScopeName()))))
{
enabled = override.getEnabled();
}
}
}
}
return (enabled);
}
}
@@ -1046,11 +1046,151 @@ info:
contact:
email: "contact@kingsrook.com"
description: "Record collaboration: favorites, recents, attachments, tags, conversations,\
\ following, timeline and people"
\ following, timeline, people and presence"
title: "QQQ Middleware API extension: collaboration"
version: "1.0.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/collaboration/presence:
delete:
description: "Leave presence. Uses the signed-in caller's permissions and record\
\ visibility."
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationPresenceLeaveV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Not found (including invisible parents)"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationMutationResultV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Leave presence"
get:
description: "Get presence snapshot. Uses the signed-in caller's permissions\
\ and record visibility."
parameters:
- description: "Parent table metadata name."
in: "query"
name: "table"
required: true
schema:
type: "string"
- description: "Parent record primary key."
in: "query"
name: "pk"
required: false
schema:
type: "string"
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Not found (including invisible parents)"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationPresenceSnapshotV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Get presence snapshot"
post:
description: "Renew presence. Uses the signed-in caller's permissions and record\
\ visibility."
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationPresenceRequestV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationErrorV1"
description: "Not found (including invisible parents)"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/CollaborationPresenceSnapshotV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Renew presence"
/qqq/v1/qbit/collaboration/favorites:
delete:
description: "Unstar a target. Uses the signed-in caller's permissions and record\
@@ -59,7 +59,7 @@ class CollaborationApiContractTest
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration"));
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(42);
assertThat(extension.getSpecs()).hasSize(45);
}
/*******************************************************************************
@@ -166,6 +166,24 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
assertError(400, "POST", "/records/document/1/tasks", "{\"title\":5}", "alice");
}
/*******************************************************************************
** Presence endpoints share parent checks and return typed merged snapshots.
*******************************************************************************/
@Test
void presenceHttp() throws Exception
{
String body = "{\"connectionId\":\"tab-one\",\"table\":\"document\",\"pk\":\"1\",\"mode\":\"EDITING\",\"fieldName\":\"title\"}";
assertThat(ok("POST", "/presence", body).get("viewers")).hasSize(1);
assertThat(ok("GET", "/presence?table=document&pk=1", null).at("/viewers/0/mode").asText()).isEqualTo("EDITING");
assertThat(ok("GET", "/presence?table=document", null).at("/viewers/0/fieldName").isNull()).isTrue();
assertError(404, "GET", "/presence?table=document&pk=3", null, "alice");
assertError(404, "POST", "/presence", body, "outsider");
assertError(400, "POST", "/presence", "{\"connectionId\":\"x\",\"table\":\"document\",\"mode\":\"BAD\"}", "alice");
assertError(400, "GET", "/presence", null, "alice");
assertThat(ok("DELETE", "/presence", "{\"connectionId\":\"tab-one\",\"table\":\"document\",\"pk\":\"1\"}").get("success").asBoolean()).isTrue();
assertThat(ok("GET", "/presence?table=document&pk=1", null).get("viewers")).isEmpty();
}
/*******************************************************************************
** Every personal operation and offset cursor runs through the HTTP boundary.
*******************************************************************************/
@@ -114,7 +114,7 @@ class CollaborationTest
CollaborationTables.SCOPE_TYPE, CollaborationTables.FEATURE, CollaborationTables.PERMISSION_MODE);
assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12);
assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName())
.containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
.containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET);
assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull();
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
@@ -177,9 +177,9 @@ class CollaborationTest
QInstance headless = new QInstance();
headless.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
new CollaborationQBitProducer().withQBitConfig(config.withAdminApp(false)).produce(headless);
assertThat(headless.getTables()).hasSize(17);
assertThat(headless.getTables()).hasSize(19);
assertThat(headless.getApps()).isEmpty();
assertThat(headless.getWidgets()).hasSize(4);
assertThat(headless.getWidgets()).hasSize(5);
}
@@ -0,0 +1,223 @@
/*
* 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.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.customfields.BuildFieldIndexes;
import com.kingsrook.qbits.collaboration.customfields.CustomFieldStorage;
import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionProviderInterface;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** JDBC contract tests capture SQL and connection state without a live PostgreSQL server.
*******************************************************************************/
public class CustomFieldSqlTest extends PersonalServiceFixture
{
/*******************************************************************************
** Use the real backend connection boundary with a deterministic driver double.
*******************************************************************************/
private QRecord setupSql() throws Exception
{
admin();
Jdbc.sql.clear();
Jdbc.parameters.clear();
Jdbc.failProbe = false;
Jdbc.failDdl = false;
Jdbc.usage = 0;
Jdbc.connections = 0;
ConnectionManager.resetConnectionProviders();
instance.addBackend(new RDBMSBackendMetaData().withName("test-postgres").withVendor("postgres").withConnectionProvider(new QCodeReference(Jdbc.class)));
instance.getTable("document").withBackendName("test-postgres").withBackendDetails(new RDBMSTableBackendDetails().withTableName("tenant.orders"));
return (CollaborationRecords.insert(CollaborationTables.FIELD_DEFINITION, new QRecord().withValue("tableName", "document")
.withValue("name", "total").withValue("label", "Total").withValue("type", "DECIMAL").withValue("status", "ACTIVE")
.withValue("filterable", true).withValue("indexState", "NONE")));
}
/*******************************************************************************
** Column absence is cached; retry after migration is explicit and executes once.
*******************************************************************************/
@Test
void columnProbeIsCachedAndReportsFailures() throws Exception
{
setupSql();
var table = instance.getTable("document");
Jdbc.failProbe = true;
assertThat(CustomFieldStorage.availability(table).json()).isFalse();
assertThat(CustomFieldStorage.availability(table).reason()).contains("column unavailable");
assertThat(Jdbc.connections).isEqualTo(1);
Jdbc.failProbe = false;
CustomFieldStorage.invalidate(instance, "document");
assertThat(CustomFieldStorage.availability(table).json()).isTrue();
assertThat(Jdbc.connections).isEqualTo(2);
assertThat(Jdbc.sql).contains("SELECT qqq_custom_fields FROM \"tenant\".\"orders\" LIMIT 0");
assertThatThrownBy(() -> CustomFieldStorage.identifier(" ")).isInstanceOf(IllegalArgumentException.class);
}
/*******************************************************************************
** Concurrent DDL requires autocommit and failures remain visible on the definition.
*******************************************************************************/
@Test
void indexConnectionAndFailureStates() throws Exception
{
var definition = setupSql();
BuildFieldIndexes.build(definition);
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("indexState")).isEqualTo("READY");
assertThat(Jdbc.sql).anyMatch(sql -> sql.contains("CREATE INDEX CONCURRENTLY") && sql.contains("::numeric"));
Jdbc.failDdl = true;
BuildFieldIndexes.build(definition);
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("indexState")).isEqualTo("FAILED");
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("statusMessage")).contains("test DDL failure");
Jdbc.failDdl = false;
definition.withValue("status", "ARCHIVED");
BuildFieldIndexes.build(definition);
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("indexState")).isEqualTo("NONE");
assertThat(Jdbc.sql).anyMatch(sql -> sql.startsWith("DROP INDEX CONCURRENTLY"));
}
/*******************************************************************************
** Used stable ids cannot be removed; labels may change and new ids may be added.
*******************************************************************************/
@Test
void activeOptionRemovalUsesBoundContainmentQuery() throws Exception
{
var definition = setupSql().withValue("type", "SELECT").withValue("options", "[{\"id\":\"a\",\"label\":\"Alpha\"}]");
var changed = new QRecord(definition).withValue("options", "[]");
Jdbc.usage = 1;
assertThatThrownBy(() -> FieldDefinitions.validate(changed, definition)).hasMessageContaining("still used");
assertThat(Jdbc.sql).anyMatch(sql -> sql.contains("qqq_custom_fields @> ?::jsonb"));
assertThat(Jdbc.parameters.get(1)).isEqualTo("{\"total\":\"a\"}");
FieldDefinitions.validate(new QRecord(definition).withValue("options", "[{\"id\":\"a\",\"label\":\"Renamed\"},{\"id\":\"b\",\"label\":\"Added\"}]"), definition);
Jdbc.usage = 0;
FieldDefinitions.validate(changed, definition);
definition.withValue("type", "MULTI_SELECT");
changed.withValue("type", "MULTI_SELECT");
FieldDefinitions.validate(changed, definition);
assertThat(Jdbc.parameters.get(1)).isEqualTo("total");
assertThat(Jdbc.parameters.get(2)).isEqualTo("[\"a\"]");
}
/*******************************************************************************
** Capture the JDBC boundary, including whether concurrent DDL was autocommitted.
*******************************************************************************/
public static class Jdbc implements ConnectionProviderInterface
{
static final List<String> sql = new ArrayList<>();
static final Map<Integer, String> parameters = new LinkedHashMap<>();
static boolean failProbe;
static boolean failDdl;
static long usage;
static int connections;
/*******************************************************************************
** No external connection configuration is needed by the test double.
*******************************************************************************/
@Override
public void init(RDBMSBackendMetaData backend)
{
}
/*******************************************************************************
** A new connection starts outside autocommit to verify the index job sets it.
*******************************************************************************/
@Override
public Connection getConnection()
{
connections++;
boolean[] autoCommit = {false};
return ((Connection) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{Connection.class}, (proxy, method, args) ->
{
return (switch(method.getName())
{
case "setAutoCommit" ->
{
autoCommit[0] = (Boolean) args[0];
yield null;
}
case "getAutoCommit" -> autoCommit[0];
case "createStatement" -> statement(null, autoCommit);
case "prepareStatement" -> statement((String) args[0], autoCommit);
case "close" -> null;
default -> throw new UnsupportedOperationException(method.getName());
});
}));
}
/*******************************************************************************
** Only supported SQL operations are accepted; unexpected calls fail the test.
*******************************************************************************/
private Object statement(String prepared, boolean[] autoCommit)
{
return (Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{prepared == null ? Statement.class : PreparedStatement.class}, (proxy, method, args) ->
{
if(method.getName().equals("close"))
{
return (null);
}
if(method.getName().equals("setString"))
{
parameters.put((Integer) args[0], (String) args[1]);
return (null);
}
String command = prepared == null ? (String) args[0] : prepared;
sql.add(command);
if(command.startsWith("SELECT qqq_custom_fields") && failProbe)
{
throw (new SQLException("column unavailable"));
}
if(method.getName().equals("execute"))
{
assertThat(autoCommit[0]).isTrue();
if(failDdl)
{
throw (new SQLException("test DDL failure"));
}
return (false);
}
return (Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{ResultSet.class}, (result, operation, values) -> switch(operation.getName())
{
case "next", "getBoolean" -> true;
case "getLong" -> usage;
case "close" -> null;
default -> throw new UnsupportedOperationException(operation.getName());
}));
}));
}
}
}
@@ -0,0 +1,297 @@
/*
* 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.math.BigDecimal;
import java.util.List;
import com.kingsrook.qbits.collaboration.customfields.BuildFieldIndexes;
import com.kingsrook.qbits.collaboration.customfields.CustomFieldTablesProvider;
import com.kingsrook.qbits.collaboration.customfields.FieldDefinitions;
import com.kingsrook.qbits.collaboration.customfields.FieldProcesses;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
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.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
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.fields.JsonPathStorage;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Exercise designer rules and storage through ordinary core actions.
*******************************************************************************/
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.
*******************************************************************************/
private QRecord draft(String name, String type) throws Exception
{
var result = new InsertAction().execute(new InsertInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
new QRecord().withValue("tableName", "document").withValue("name", name).withValue("label", name).withValue("type", type)))).getRecords().getFirst();
assertThat(result.getErrors()).isNullOrEmpty();
return (result);
}
/*******************************************************************************
** A published numeric JSON field is writable, filterable, validated and classified.
*******************************************************************************/
@Test
void memoryFieldsUseNormalActionsAndPreserveClassification() throws Exception
{
admin();
QRecord definition = draft("score", "INTEGER");
var result = new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
new QRecord().withValue("id", definition.getValue("id")).withValue("minValue", 1).withValue("maxValue", 20).withValue("classification", "PHI")
.withValue("sectionName", "scores")))).getRecords().getFirst();
assertThat(result.getErrors()).isNullOrEmpty();
FieldDefinitions.apply(definition.getValue("id"), "Publish");
var field = instance.getTable("document").getField("cf_score");
assertThat(field.getBackendStorage()).isInstanceOf(JsonPathStorage.class);
assertThat(AiDataClassifications.ofField(instance.getTable("document"), "cf_score")).isEqualTo(DataClassification.PHI);
assertThat(instance.getTable("document").getSections()).anySatisfy(section -> assertThat(section.getFieldNames()).contains("cf_score"));
login("alice");
var inserted = new InsertAction().execute(new InsertInput("document").withRecords(List.of(new QRecord().withValue("id", 4).withValue("tenant", "a")
.withValue("title", "Fourth").withValue("cf_score", 7)))).getRecords().getFirst();
assertThat(inserted.getErrors()).isNullOrEmpty();
assertThat(query("cf_score", QCriteriaOperator.GREATER_THAN, 5)).extracting(row -> row.getValueInteger("id")).containsExactly(4);
var updated = new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", 4).withValue("cf_score", 12)))).getRecords().getFirst();
assertThat(updated.getErrors()).isNullOrEmpty();
assertThat(query("cf_score", QCriteriaOperator.EQUALS, 12)).hasSize(1);
var invalid = new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", 4).withValue("cf_score", 30)))).getRecords().getFirst();
assertThat(invalid.getErrors()).isNotEmpty();
admin();
assertThat(FieldDefinitions.valueCount(FieldDefinitions.get(definition.getValue("id")))).isEqualTo(1);
FieldDefinitions.apply(definition.getValue("id"), "Archive");
assertThat(instance.getTable("document").getFields()).doesNotContainKey("cf_score");
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("status")).isEqualTo("ARCHIVED");
}
/*******************************************************************************
** Normal permissions and direct table operations cannot bypass lifecycle rules.
*******************************************************************************/
@Test
void validationPermissionsAndTypeRules() throws Exception
{
assertThatThrownBy(() -> draft("denied", "STRING")).hasMessageContaining("collaboration.fields.manage");
login("manager", "collaboration.fields.manage", "collaboration.view");
QRecord row = draft("choice", "SELECT");
var duplicate = new InsertAction().execute(new InsertInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(new QRecord()
.withValue("tableName", "document").withValue("name", "choice").withValue("label", "Duplicate").withValue("type", "SELECT")))).getRecords().getFirst();
assertThat(duplicate.getErrors()).isNotEmpty();
assertThatThrownBy(() -> draft("Bad-name", "STRING")).hasMessageContaining("name");
instance.getTable("document").withField(new QFieldMetaData("cf_clash", QFieldType.STRING));
assertThatThrownBy(() -> draft("clash", "STRING")).hasMessageContaining("clash");
instance.getTable("document").getFields().remove("cf_clash");
assertThatThrownBy(() -> draft("link", "RECORD_LINK")).hasMessageContaining("possible value");
assertThat(new CustomFieldTablesProvider().getPossibleValue("document")).isNotNull();
assertThat(new CustomFieldTablesProvider().getPossibleValue("missing")).isNull();
var changed = new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(new QRecord()
.withValue("id", row.getValue("id")).withValue("type", "STRING")))).getRecords().getFirst();
assertThat(changed.getErrors()).isNullOrEmpty();
FieldDefinitions.apply(row.getValue("id"), "Publish");
assertThatThrownBy(() -> FieldDefinitions.apply(row.getValue("id"), "Publish")).hasMessageContaining("DRAFT");
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
new QRecord().withValue("id", row.getValue("id")).withValue("type", "INTEGER"))))).hasMessageContaining("DRAFT");
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withRecords(List.of(
new QRecord().withValue("id", row.getValue("id")).withValue("status", "ARCHIVED"))))).hasMessageContaining("actions");
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");
}
/*******************************************************************************
** Definitions contribute all supported types, options, search and defaults.
*******************************************************************************/
@Test
void contributionMappingsAndGeneration() throws Exception
{
admin();
long before = RuntimeMetaDataOverlay.getGeneration(instance);
for(String type : List.of("STRING", "TEXT", "DECIMAL", "BOOLEAN", "DATE", "DATE_TIME", "SELECT", "MULTI_SELECT", "USER", "RECORD_LINK"))
{
QRecord row = new QRecord().withValue("tableName", "document").withValue("name", type.toLowerCase()).withValue("label", type)
.withValue("type", type).withValue("status", "ACTIVE").withValue("linkTableName", CollaborationTables.USER)
.withValue("helpText", "Helpful text").withValue("maxLength", type.equals("STRING") ? 30 : null)
.withValue("searchable", type.equals("STRING")).withValue("options", "[{\"id\":\"a\",\"label\":\"Alpha\"}]");
CollaborationRecords.insert(CollaborationTables.FIELD_DEFINITION, row);
}
RuntimeMetaDataOverlay.refresh(instance);
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isGreaterThan(before);
assertThat(instance.getTable("document").getField("cf_decimal").getType()).isEqualTo(QFieldType.DECIMAL);
assertThat(instance.getTable("document").getField("cf_multi_select").getType()).isEqualTo(QFieldType.STRING);
assertThat(instance.getTable("document").getSearchFields()).contains("cf_string");
assertThat(instance.getTable("document").getField("cf_user").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
assertThat(instance.getTable("document").getField("cf_select").getPossibleValueSourceName()).startsWith("collaboration_field_options_");
QRecord draft = draft("transaction", "STRING");
QBackendTransaction tx = new QBackendTransaction();
before = RuntimeMetaDataOverlay.getGeneration(instance);
new UpdateAction().execute(new UpdateInput(CollaborationTables.FIELD_DEFINITION).withTransaction(tx).withRecords(List.of(new QRecord()
.withValue("id", draft.getValue("id")).withValue("label", "Updated"))));
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isEqualTo(before);
tx.commit();
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).isGreaterThan(before);
}
/*******************************************************************************
** Build migration SQL without opening a PostgreSQL connection or executing DDL.
*******************************************************************************/
@Test
void managedFalseRecordsDdl() throws Exception
{
admin();
QRecord row = draft("amount", "DECIMAL");
row.withValue("filterable", true).withValue("status", "ACTIVE");
var backend = new RDBMSBackendMetaData().withVendor("postgres").withName("postgres");
instance.addBackend(backend);
instance.getTable("document").setBackendName("postgres");
config.withManagedIndexes(false);
assertThat(config.getManagedIndexes()).isFalse();
BuildFieldIndexes.build(row);
QRecord stored = FieldDefinitions.get(row.getValue("id"));
assertThat(stored.getValueString("indexState")).isEqualTo("NONE");
assertThat(stored.getValueString("indexDdl")).contains("CREATE INDEX CONCURRENTLY IF NOT EXISTS", "qqq_custom_fields->>'amount'", "::numeric");
assertThat(BuildFieldIndexes.ddl(instance.getTable("document"), new QRecord(row).withValue("type", "STRING").withValue("searchable", true), true)).contains("USING gin", "gin_trgm_ops");
assertThat(BuildFieldIndexes.ddl(instance.getTable("document"), new QRecord(row).withValue("status", "ARCHIVED"), false)).startsWith("DROP INDEX CONCURRENTLY");
assertThat(BuildFieldIndexes.ddl(instance.getTable("document"), new QRecord(row).withValue("filterable", false), false)).isNull();
assertThat(CollaborationSchema.customFieldsDdl("orders")).contains("ADD COLUMN IF NOT EXISTS qqq_custom_fields JSONB", "jsonb_path_ops");
assertThat(FieldProcesses.metadata()).extracting(process -> process.getLabel()).containsExactly("Publish field", "Archive field", "Rebuild index");
}
/*******************************************************************************
** An unsupported JSON backend gets typed, read-only subquery filters.
*******************************************************************************/
@Test
void eavFiltersAndSearch() throws Exception
{
admin();
instance.addBackend(new RDBMSBackendMetaData().withName("fallback").withVendor("h2"));
instance.getTable("document").setBackendName("fallback");
QRecord definition = draft("rating", "INTEGER");
FieldDefinitions.apply(definition.getValue("id"), "Publish");
instance.getTable("document").setBackendName("memory");
var virtual = instance.getTable("document").getVirtualField("cf_rating");
assertThat(virtual).isNotNull();
assertThat(virtual.getIsQuerySelectable()).isFalse();
assertThat(virtual.getIsEditable()).isFalse();
assertThat(FieldDefinitions.get(definition.getValue("id")).getValueString("storage")).isEqualTo("EAV");
CollaborationRecords.insert(CollaborationTables.FIELD_VALUE, new QRecord().withValue("tableName", "document").withValue("recordId", "1")
.withValue("fieldName", "rating").withValue("valueNumber", new BigDecimal("7")).withValue("parentDeleted", false));
login("alice");
assertThat(query("cf_rating", QCriteriaOperator.GREATER_THAN, 5)).extracting(row -> row.getValueInteger("id")).containsExactly(1);
assertThat(query("cf_rating", QCriteriaOperator.NOT_EQUALS, 7)).extracting(row -> row.getValueInteger("id")).containsExactly(2);
assertThat(new QueryAction().execute(new QueryInput("document").withFilter(new QQueryFilter().withCriteria("cf_rating", QCriteriaOperator.IS_BLANK))).getRecords())
.extracting(row -> row.getValueInteger("id")).containsExactly(2);
admin();
assertThat(FieldDefinitions.valueCount(FieldDefinitions.get(definition.getValue("id")))).isEqualTo(1);
var expander = new com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander();
assertThat(expander.expandSearchTerm(instance.getTable("document"), "text", com.kingsrook.qqq.backend.core.actions.tables.query.SearchTermKind.TEXT, null)).isNotNull();
assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("DATE")).isEqualTo("valueDate");
assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("DATE_TIME")).isEqualTo("valueDateTime");
assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("BOOLEAN")).isEqualTo("valueBoolean");
assertThat(com.kingsrook.qbits.collaboration.customfields.FieldCriteriaExpander.valueColumn("STRING")).isEqualTo("valueText");
}
/*******************************************************************************
** Review and load execute the real lifecycle and retain failures in their summaries.
*******************************************************************************/
@Test
void streamedActionsAndScheduledJob() throws Exception
{
admin();
var row = draft("action", "STRING");
var process = instance.getProcess("collaborationPublishField");
var input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName(process.getName()).withRecords(List.of(row));
var output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
var review = new FieldProcesses.Review();
review.runOnePage(input, output);
assertThat(output.getRecords()).hasSize(1);
assertThat(review.getProcessSummary(output, false)).hasSize(1);
var apply = new FieldProcesses.Apply();
apply.runOnePage(input, output);
assertThat(apply.getProcessSummary(output, true)).hasSize(1);
review.runOnePage(input, output);
apply.runOnePage(input, output);
assertThat(review.getProcessSummary(output, false)).hasSize(2);
input.withProcessName("collaborationArchiveField");
review.runOnePage(input, output);
FieldDefinitions.apply(row.getValue("id"), "Rebuild");
new BuildFieldIndexes().run(input, output);
assertThat(BuildFieldIndexes.metadata(config.withSchedulerName("scheduler")).getSchedule()).isNotNull();
config.withSchedulerName(null);
config.setManagedIndexes(true);
assertThat(config.getManagedIndexes()).isTrue();
var draft = draft("delete", "STRING");
new DeleteAction().execute(new DeleteInput(CollaborationTables.FIELD_DEFINITION).withPrimaryKeys(List.of(draft.getValue("id"))));
assertThatThrownBy(() -> FieldDefinitions.get(draft.getValue("id"))).hasMessageContaining("Not found");
}
/*******************************************************************************
** Query paths remain ordinary filters; callers retain their record locks.
*******************************************************************************/
private List<QRecord> query(String field, QCriteriaOperator operator, java.io.Serializable value) throws Exception
{
return (new QueryAction().execute(new QueryInput("document").withFilter(new QQueryFilter().withCriteria(field, operator, value))).getRecords());
}
}
@@ -119,6 +119,8 @@ public abstract class PersonalServiceFixture
@AfterEach
public void tearDown()
{
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.close(instance);
com.kingsrook.qqq.backend.core.realtime.RealtimeBus.close(instance);
QContext.clear();
MemoryRecordStore.getInstance().reset();
}
@@ -0,0 +1,187 @@
/*
* 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.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qbits.collaboration.presence.PresenceService;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
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.realtime.InMemoryRealtimeBus;
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Two independent pods, a real in-memory bus, controlled time and real permissions.
*******************************************************************************/
class PresenceTest extends PersonalServiceFixture
{
/*******************************************************************************
** Renewals replicate even without a visible change; tabs merge until all expire.
*******************************************************************************/
@Test
void twoPodsMergeRenewExpireAndLeave() throws Exception
{
var clock = new MutableClock();
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES);
var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
{
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
bus.subscribe(events::add);
first.heartbeat("alice", "Alice", "one", "document", "1", "VIEWING", null);
assertThat(second.snapshot("document", "1")).hasSize(1);
clock.advance(1000);
second.heartbeat("alice", "Alice", "two", "document", "1", "EDITING", "title");
assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title"));
assertThat(first.snapshot("document", null).getFirst().fieldName()).isNull();
int changes = (int) events.stream().filter(event -> event.getEvent().equals("presence.changed")).count();
clock.advance(20000);
first.heartbeat("alice", "Alice", "two", "document", "1", "EDITING", "title");
assertThat(events.stream().filter(event -> event.getEvent().equals("presence.changed")).count()).isEqualTo(changes);
clock.advance(25000);
first.expire();
second.expire();
assertThat(first.snapshot("document", "1")).hasSize(1);
assertThat(second.snapshot("document", "1")).hasSize(1);
second.leave("alice", "two", "document", "1");
assertThat(first.snapshot("document", "1")).isEmpty();
first.heartbeat("bob", "Bob", "three", "document", null, "VIEWING", null);
clock.advance(45000);
assertThat(second.snapshot("document", null)).isEmpty();
assertThat(first.counts()).isEmpty();
assertThat(events).anySatisfy(event ->
{
assertThat(event.getEvent()).isEqualTo("presence.heartbeat");
assertThat(event.getChannel()).isEqualTo("instance:presence");
});
assertThat(events.stream().filter(event -> event.getChannel().equals("table:document"))).allSatisfy(event -> assertThat(event.getData()).doesNotContainKey("pk"));
}
}
/*******************************************************************************
** At capacity, existing leases renew while new targets are refused on both pods.
*******************************************************************************/
@Test
void boundedRegistryAndMalformedMessages() throws Exception
{
var clock = new MutableClock();
assertThat(PresenceRegistry.MAX_ENTRIES).isEqualTo(50000);
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, 2); var second = new PresenceRegistry(bus, clock, 2))
{
assertThat(first.heartbeat("alice", "Alice", "one", "document", "1", "VIEWING", null)).isTrue();
assertThat(first.heartbeat("bob", "Bob", "one", "document", "1", "VIEWING", null)).isTrue();
assertThat(second.heartbeat("carol", "Carol", "one", "document", "1", "VIEWING", null)).isFalse();
assertThat(first.heartbeat("alice", "Alice", "one", "document", "1", "EDITING", "title")).isTrue();
assertThat(second.counts()).containsEntry("document", 2);
bus.publish(new RealtimeMessage().withChannel(PresenceRegistry.INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(java.util.Map.of()));
assertThat(second.snapshot("document", "1")).hasSize(2);
clock.advance(45000);
assertThat(first.heartbeat("carol", "Carol", "one", "document", "1", "VIEWING", null)).isTrue();
assertThat(first.snapshot("document", "1")).hasSize(1);
}
}
/*******************************************************************************
** Denials are opaque, current sessions are checked, and nothing enters the directory.
*******************************************************************************/
@Test
void accessFeatureAndSnapshot() throws Exception
{
var service = new PresenceService();
assertThat(service.heartbeat("one", "document", "01", "EDITING", "title")).hasSize(1);
assertThat(service.snapshot("document", "1")).extracting(PresenceRegistry.Viewer::userId).containsExactly("alice");
assertThat(service.snapshot("document", null)).hasSize(1);
assertThatThrownBy(() -> service.heartbeat("one", "document", "3", "VIEWING", null)).isInstanceOf(QNotFoundException.class);
assertThatThrownBy(() -> service.snapshot("missing", null)).isInstanceOf(QNotFoundException.class);
assertThatThrownBy(() -> service.heartbeat("one", "document", "1", "BAD", null)).hasMessageContaining("Mode");
assertThatThrownBy(() -> service.heartbeat("one", "document", "1", "VIEWING", "bad")).hasMessageContaining("field");
assertThatThrownBy(() -> service.heartbeat("", "document", null, "VIEWING", null)).hasMessageContaining("connectionId");
login("bob");
service.leave("one", "document", "1");
assertThat(service.snapshot("document", "1")).hasSize(1);
login("alice");
QContext.getQSession().getSecurityKeyValues().clear();
assertThatThrownBy(() -> service.heartbeat("one", "document", "1", "VIEWING", null)).isInstanceOf(QNotFoundException.class);
login("alice");
assertThat(service.snapshot("document", "1")).isEmpty();
service.heartbeat("table", "document", null, "VIEWING", null);
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.PRESENCE, false);
RuntimeMetaDataOverlay.refresh(instance);
assertThatThrownBy(() -> service.snapshot("document", null)).isInstanceOf(QNotFoundException.class);
assertThatThrownBy(() -> service.heartbeat("table", "document", null, "VIEWING", null)).isInstanceOf(QNotFoundException.class);
admin();
assertThat(CollaborationRecords.query(com.kingsrook.qbits.collaboration.model.CollaborationTables.USER, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty();
assertThat(CollaborationHome.render(Instant.now())).contains("Who's here", "Custom fields", "Index NONE");
}
/*******************************************************************************
** Clock controls the TTL boundary without sleeps.
*******************************************************************************/
private static class MutableClock extends Clock
{
private Instant now = Instant.parse("2026-10-13T12:00:00Z");
/*******************************************************************************
** Move deterministic time forward.
*******************************************************************************/
void advance(long millis)
{
now = now.plusMillis(millis);
}
/*******************************************************************************
** Fixed UTC test zone.
*******************************************************************************/
@Override
public ZoneId getZone()
{
return (ZoneId.of("UTC"));
}
/*******************************************************************************
** Zone does not affect elapsed time.
*******************************************************************************/
@Override
public Clock withZone(ZoneId zone)
{
return (this);
}
/*******************************************************************************
** Current test instant.
*******************************************************************************/
@Override
public Instant instant()
{
return (now);
}
}
}
@@ -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();
}
}
}
@@ -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);
}
}
}