Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da932fb309
|
||
|
|
66189ec110 | ||
|
|
23d3365437
|
||
|
|
f0b8fc9141
|
||
|
|
6cd78a803e | ||
|
|
b4032baf70 | ||
|
|
d2ad22eb97 | ||
|
|
66b5bf2bea | ||
|
|
d828d1677d
|
||
|
|
ef2ee45b5a
|
||
|
|
03669391d0
|
||
|
|
7e63314413
|
||
|
|
68fe96c22f
|
||
|
|
c9954efef4
|
||
|
|
e696d9edaf
|
||
|
|
42cf51325a
|
||
|
|
95506ed706
|
||
|
|
bc992fd5f8
|
||
|
|
7b7c921079
|
||
|
|
08498dd1db
|
No files matched your search
+18
-1
@@ -4,11 +4,28 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Email through qbit-email 0.1.0-RC.1: new optional dependency and `EmailCollaborationNotifier` (templates `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder`), the default notifier when qbit-email is produced in the instance and no `notifier` is configured; `NoOpNotifier` otherwise. One NOTIFICATION message per recipient, no comment bodies, record labels only when field access allows. New `recipientEmailResolver` config hook; `CollaborationNotification` gains optional `sourceId` and `actorUserId`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Timeline audit summaries read "Edited by Alice Morgan: Title, Total" or "Created by Alice Morgan" instead of "Record was Edited"; detail rows are unchanged.
|
||||
- The demo server chooses its user per browser session (`?user=bob`, remembered in a cookie) instead of once per JVM, and sends the person's name to the frontend so it no longer shows Anonymous.
|
||||
- The share recipient search and mention candidates no longer offer the caller to themself.
|
||||
- The demo server configures `frontendBaseUrl`, so record links are absolute URLs (the API already returned absolute links once it is set).
|
||||
- Tag filters accept tag ids as any JSON number or numeric string; other strings remain names.
|
||||
- Collaboration home rebuilt from standard KPI and table widgets with empty states; the demo Admin parent app is reachable so Collaboration appears in the app tree.
|
||||
|
||||
- Dependent possible values for comment parents (thread root/replies), feature-setting scope names (apps/eligible tables/instance), and comment field anchors (session-visible fields), with RC.5 action-level tests.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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,79 @@ 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.
|
||||
Pick the user per browser: add `user=` to the page load, for example
|
||||
`http://localhost:8098/?createMockSession=true&user=bob`. The server remembers the choice in a
|
||||
`collab_dev_user` cookie, so **open a second browser with `?user=bob` to see presence** (your
|
||||
first browser keeps its own user; browsers on the same profile share the cookie). Without a
|
||||
choice, `-Dcollab.dev.user=alice|bob|admin` (one value, not the literal list) is the default.
|
||||
The MOCK session customizer supplies stable identities, display names (the sidebar shows
|
||||
Alice Morgan, not Anonymous) and real permissions, also used by recipient access checks.
|
||||
Shared record links are absolute (`frontendBaseUrl` is `http://localhost:<port>`):
|
||||
|
||||
| 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):
|
||||
@@ -66,7 +139,7 @@ Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation
|
||||
|
||||
All tables include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold.
|
||||
|
||||
The home reports persisted enabled settings by scope type, people known and people seen within the inclusive last seven days, plus Favorites and Recently viewed row counts and distinct user counts, attachment count and total bytes, comments in the last seven days, open root threads, and follower subscriptions (admins). Attachment aggregates are explicitly approximate until core can mirror parent locks. Recent activity is based on `lastSeenAt`, independently of the directory's `active` account flag. Empty states explain missing data. These counts do not represent resolved feature enablement on host tables.
|
||||
The home is four full-width standard widgets (no custom HTML, no hard-coded colors, so dark mode works): an Overview of KPIs (comments this week, open threads, open and overdue tasks, open access requests, attachments with a human-readable total size such as 4.7 KB, active custom fields), a Tags table (label, table or All tables, records), a Custom fields table (name, table, status, index state) and an admin-only Who's here table of live viewer counts per table. Every block has a real empty-state message. Counts use the caller's session and permissions; attachment aggregates remain approximate until core can mirror parent locks.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -196,7 +269,8 @@ UI API endpoints are described below. Runtime feature enablement, mirrored WRITE
|
||||
| `defaultMaxBytes` | `104857600` (100 MiB) | Positive byte limit, capped by the bucket's `maxUploadBytes`. |
|
||||
| `blockedExtensions` | Empty | Case-insensitive extension denylist, with or without leading dots. |
|
||||
| `recentsPerUser` | `200` | Positive retention count per user. Enforced by the trim process. |
|
||||
| `notifier` | `QCodeReference(NoOpNotifier.class)` | Implements `CollaborationNotifier`; replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released. |
|
||||
| `notifier` | None | Implements `CollaborationNotifier`; replace with a QAlerts.notify adapter when QRun-IO/qqq#1125 is released. When unset: `EmailCollaborationNotifier` if qbit-email is on the classpath and produced in the instance, otherwise `NoOpNotifier`. |
|
||||
| `recipientEmailResolver` | People directory | `Function<String, String>` from user id to email address for the email notifier. Unset reads the `email` of the matching `collaboration_user` row; no address means no email. |
|
||||
| `userSessionProvider` | None | `QCodeReference` implementing `UserSessionProviderInterface`; missing user sessions mean unknown access. |
|
||||
| `followActionTables` | Empty | Existing host tables to receive standard Follow and Unfollow Review/Result processes. |
|
||||
| `followChangeTables` | Empty | Existing host tables whose UPDATE events should produce five-minute follow digests. |
|
||||
@@ -204,6 +278,18 @@ UI API endpoints are described below. Runtime feature enablement, mirrored WRITE
|
||||
| `parentAppName` | None | Optional existing parent, such as `admin`. |
|
||||
| `adminApp` | `true` | Set false to omit the app/home. Record child-list widgets remain available. |
|
||||
|
||||
## Email through qbit-email
|
||||
|
||||
qbit-email 0.1.0-RC.1 is an optional dependency (`<optional>true</optional>`). When it is on the classpath, produced in the same instance, and `notifier` is not configured, mentions, shares and task notifications are emailed by `EmailCollaborationNotifier` (package `email`, the only class that references qbit-email). Without qbit-email nothing changes: `NoOpNotifier` sends nothing. A configured `notifier` always wins.
|
||||
|
||||
The parent 2.1.0 guardrail allowlists explicitly include the qbit-email artifact and API package, preserving the parent's existing allowances. The package allowance includes the root because `EmailRuntime.config` lives there; keep all direct email references inside the adapter. Strict UI API route checks are enabled.
|
||||
|
||||
- Templates (NOTIFICATION, Markdown, owner `qbit-collaboration`), registered on first send and never overwritten: `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder` (task assignment, reminder and overdue). Follow digests are not emailed.
|
||||
- One message per recipient, idempotency key `collaboration:<mention|share|task>:<event id>:<recipient user id>`, linked to the parent record, sender `qbit-collaboration`. A repeat is a duplicate. The event id is the mention, share, task or assignment row id (task reminders add the due field); `CollaborationNotification` carries it as `sourceId`, with the acting user as `actorUserId`.
|
||||
- No comment bodies, task titles, notes or share messages are emailed, since they may hold PHI. The email says who did what on which record, with a link. The record label is read as the recipient (needs `userSessionProvider`) and used only when `FieldAccessHelper.isRecordLabelFullAccess` allows it (reflective guard); otherwise the text says "a record in <table label>". Variables are classified CONFIDENTIAL so the templates need no PHI approval.
|
||||
- The link is sent only when absolute: set `frontendBaseUrl`.
|
||||
- Recipient addresses come from `withRecipientEmailResolver(Function<userId, String>)`, else the people directory email. Refused or suppressed sends are logged by user id and status only; delivery problems never fail the collaboration action.
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
1. Register the host backend and optional Admin app, then invoke the producer above.
|
||||
@@ -510,3 +596,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.
|
||||
@@ -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.
|
||||
@@ -22,6 +22,12 @@
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<!-- qbit-email from Maven Central -->
|
||||
<qbit-email.version>0.1.0-RC.1</qbit-email.version>
|
||||
<!-- Optional email integration: only EmailCollaborationNotifier links to its API. -->
|
||||
<!-- EmailRuntime is in the root package; retain the parent's existing allowances. -->
|
||||
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-email</qbit.guardrails.allowedQbitArtifacts>
|
||||
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email</qbit.guardrails.allowedApiPackages>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
@@ -67,15 +73,32 @@
|
||||
</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>
|
||||
<version>0.1.0-RC.2</version>
|
||||
<version>0.1.0-RC.4</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
<!-- optional: email through qbit-email when it is produced in the host instance (EmailCollaborationNotifier) -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-email</artifactId>
|
||||
<version>${qbit-email.version}</version>
|
||||
<optional>true</optional>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>*</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</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)
|
||||
|
||||
@@ -25,7 +25,7 @@ package com.kingsrook.qbits.collaboration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.notify.NoOpNotifier;
|
||||
import java.util.function.Function;
|
||||
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;
|
||||
@@ -46,7 +46,8 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
private Map<String, List<RecordFeature>> tableFeatures = new LinkedHashMap<>();
|
||||
private List<String> mirroredSecurityKeyTypes = List.of();
|
||||
|
||||
private QCodeReference notifier = new QCodeReference(NoOpNotifier.class);
|
||||
private QCodeReference notifier = null;
|
||||
private Function<String, String> recipientEmailResolver = null;
|
||||
private QCodeReference userSessionProvider = null;
|
||||
private String defaultTagMode = "FREE";
|
||||
private List<String> tagActionTables = List.of();
|
||||
@@ -57,6 +58,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;
|
||||
@@ -300,7 +302,6 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors);
|
||||
}
|
||||
assertCondition(List.of("FREE", "APPROVED_ONLY").contains(defaultTagMode == null ? "" : defaultTagMode), "defaultTagMode must be FREE or APPROVED_ONLY", errors);
|
||||
assertCondition(notifier != null, "notifier is required", errors);
|
||||
for(List<String> tables : List.of(followActionTables, followChangeTables, tagActionTables, assignActionTables))
|
||||
{
|
||||
for(String table : tables)
|
||||
@@ -423,13 +424,32 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get notifier.
|
||||
** Get notifier. Null (the default) means: email through qbit-email when it is
|
||||
** produced in the instance, otherwise no delivery transport.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getNotifier()
|
||||
{
|
||||
return (notifier);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the user id to email address lookup used by the email notifier.
|
||||
*******************************************************************************/
|
||||
public Function<String, String> getRecipientEmailResolver()
|
||||
{
|
||||
return (recipientEmailResolver);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure how the email notifier finds a recipient address from a user id.
|
||||
** Null (the default) uses the email stored in the collaboration people directory.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withRecipientEmailResolver(Function<String, String> recipientEmailResolver)
|
||||
{
|
||||
this.recipientEmailResolver = recipientEmailResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure notifier.
|
||||
*******************************************************************************/
|
||||
@@ -629,4 +649,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())
|
||||
@@ -160,7 +162,7 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
}
|
||||
if(Boolean.TRUE.equals(qBitConfig.getAdminApp()))
|
||||
{
|
||||
output.add(CollaborationHome.widget());
|
||||
CollaborationHome.widgets().forEach(output::add);
|
||||
output.add(CollaborationHome.app(qBitConfig));
|
||||
}
|
||||
output.addSelfToInstance(instance);
|
||||
|
||||
@@ -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)
|
||||
|
||||
+125
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public final class CollaborationSchemas
|
||||
/*******************************************************************************
|
||||
** An attachment visible through its parent record; no storage coordinates are exposed.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete) implements ToSchema
|
||||
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
@@ -89,7 +89,8 @@ public final class CollaborationSchemas
|
||||
Map.entry("description", new Schema().withType(Type.STRING).withDescription("Optional description.").withNullable(true)),
|
||||
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("Uploader identity.").withNullable(true)),
|
||||
Map.entry("createDate", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Upload time.").withNullable(true)),
|
||||
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true))))));
|
||||
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true)),
|
||||
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Uploader display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +202,7 @@ public final class CollaborationSchemas
|
||||
/*******************************************************************************
|
||||
** A previous comment body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationCommentRevisionV1(Integer id, String body, Instant editedAt, String editedByUserId) implements ToSchema
|
||||
public record CollaborationCommentRevisionV1(Integer id, String body, Instant editedAt, String editedByUserId, String editedByDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
@@ -213,14 +214,15 @@ public final class CollaborationSchemas
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("Revision identifier.").withNullable(true)),
|
||||
Map.entry("body", new Schema().withType(Type.STRING).withDescription("Previous body text.").withNullable(true)),
|
||||
Map.entry("editedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Time the body was replaced.").withNullable(true)),
|
||||
Map.entry("editedByUserId", new Schema().withType(Type.STRING).withDescription("Editor identity.").withNullable(true))))));
|
||||
Map.entry("editedByUserId", new Schema().withType(Type.STRING).withDescription("Editor identity.").withNullable(true)),
|
||||
Map.entry("editedByDisplayName", new Schema().withType(Type.STRING).withDescription("Editor display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A subscription to record changes and comments.
|
||||
*******************************************************************************/
|
||||
public record CollaborationFollowV1(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil) implements ToSchema
|
||||
public record CollaborationFollowV1(Integer id, String userId, String reason, Boolean notifyOnChange, Boolean notifyOnComment, Instant mutedUntil, String userDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
@@ -234,7 +236,8 @@ public final class CollaborationSchemas
|
||||
Map.entry("reason", new Schema().withType(Type.STRING).withEnumValues(List.of("MANUAL", "AUTHOR", "COMMENTED", "MENTIONED", "ASSIGNED", "SHARED")).withDescription("How following began.").withNullable(true)),
|
||||
Map.entry("notifyOnChange", new Schema().withType(Type.BOOLEAN).withDescription("Receive record change digests.").withNullable(true)),
|
||||
Map.entry("notifyOnComment", new Schema().withType(Type.BOOLEAN).withDescription("Receive new comment notifications.").withNullable(true)),
|
||||
Map.entry("mutedUntil", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Notifications muted until this time, if set.").withNullable(true))))));
|
||||
Map.entry("mutedUntil", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Notifications muted until this time, if set.").withNullable(true)),
|
||||
Map.entry("userDisplayName", new Schema().withType(Type.STRING).withDescription("Follower display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,14 +681,14 @@ public final class CollaborationSchemas
|
||||
{
|
||||
Map<String, Schema> schemas = new TreeMap<>();
|
||||
schemas.put("CollaborationFavoriteV1", new CollaborationFavoriteV1(null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationAttachmentV1", new CollaborationAttachmentV1(null, null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationAttachmentV1", new CollaborationAttachmentV1(null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTagV1", new CollaborationTagV1(null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationPersonV1", new CollaborationPersonV1(null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationMentionV1", new CollaborationMentionV1(null, null).toSchema());
|
||||
schemas.put("CollaborationMentionCandidateV1", new CollaborationMentionCandidateV1(null, null).toSchema());
|
||||
schemas.put("CollaborationCommentV1", new CollaborationCommentV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationCommentRevisionV1", new CollaborationCommentRevisionV1(null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationFollowV1", new CollaborationFollowV1(null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationCommentRevisionV1", new CollaborationCommentRevisionV1(null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationFollowV1", new CollaborationFollowV1(null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationFollowStateV1", new CollaborationFollowStateV1(null, null).toSchema());
|
||||
schemas.put("CollaborationTimelineDetailV1", new CollaborationTimelineDetailV1(null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTimelineItemV1", new CollaborationTimelineItemV1(null, null, null, null, null, null, null, null, null).toSchema());
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
package com.kingsrook.qbits.collaboration.api;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationAttachmentV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationCommentRevisionV1;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationSchemas.CollaborationCommentV1;
|
||||
@@ -40,10 +44,13 @@ import com.kingsrook.qbits.collaboration.comments.MentionCandidate;
|
||||
import com.kingsrook.qbits.collaboration.comments.MentionEntry;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowEntry;
|
||||
import com.kingsrook.qbits.collaboration.people.PeopleService;
|
||||
import com.kingsrook.qbits.collaboration.people.PersonEntry;
|
||||
import com.kingsrook.qbits.collaboration.tags.TagEntry;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineDetail;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineItem;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -67,13 +74,39 @@ final class CollaborationViews
|
||||
value.id(), value.targetType(), value.targetName(), value.recordId(), value.label(), value.folder(), value.sortOrder(), value.available(), value.href()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One display name per distinct user id, from a single directory query.
|
||||
*******************************************************************************/
|
||||
static Map<String, String> names(String... userIds) throws QException
|
||||
{
|
||||
return (new PeopleService().displayNames(Arrays.asList(userIds)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The display name for an id; null stays null, unknown ids show themselves.
|
||||
*******************************************************************************/
|
||||
private static String name(Map<String, String> names, String userId)
|
||||
{
|
||||
return (userId == null ? null : names.getOrDefault(userId, userId));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project attachment display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationAttachmentV1 attachment(AttachmentEntry value)
|
||||
static CollaborationAttachmentV1 attachment(AttachmentEntry value) throws QException
|
||||
{
|
||||
return (new CollaborationAttachmentV1(
|
||||
value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete()));
|
||||
return (attachments(List.of(value)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project attachments, resolving uploader names in one batch.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationAttachmentV1> attachments(List<AttachmentEntry> values) throws QException
|
||||
{
|
||||
Map<String, String> names = names(values.stream().map(AttachmentEntry::createdByUserId).toArray(String[]::new));
|
||||
return (values.stream().map(value -> new CollaborationAttachmentV1(
|
||||
value.id(), value.tableName(), value.recordId(), value.fileName(), value.contentType(), value.sizeBytes(), value.sha256(), value.description(), value.createdByUserId(), value.createDate(), value.canDelete(),
|
||||
name(names, value.createdByUserId()))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -115,19 +148,29 @@ final class CollaborationViews
|
||||
/*******************************************************************************
|
||||
** Project commentrevision display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationCommentRevisionV1 commentRevision(CommentRevision value)
|
||||
static List<CollaborationCommentRevisionV1> commentRevisions(List<CommentRevision> values) throws QException
|
||||
{
|
||||
return (new CollaborationCommentRevisionV1(
|
||||
value.id(), value.body(), value.editedAt(), value.editedByUserId()));
|
||||
Map<String, String> names = names(values.stream().map(CommentRevision::editedByUserId).toArray(String[]::new));
|
||||
return (values.stream().map(value -> new CollaborationCommentRevisionV1(
|
||||
value.id(), value.body(), value.editedAt(), value.editedByUserId(), name(names, value.editedByUserId()))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project follow display data.
|
||||
*******************************************************************************/
|
||||
static CollaborationFollowV1 follow(FollowEntry value)
|
||||
static CollaborationFollowV1 follow(FollowEntry value) throws QException
|
||||
{
|
||||
return (new CollaborationFollowV1(
|
||||
value.id(), value.userId(), value.reason(), value.notifyOnChange(), value.notifyOnComment(), value.mutedUntil()));
|
||||
return (follows(List.of(value)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project followers, resolving names in one batch.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationFollowV1> follows(List<FollowEntry> values) throws QException
|
||||
{
|
||||
Map<String, String> names = names(values.stream().map(FollowEntry::userId).toArray(String[]::new));
|
||||
return (values.stream().map(value -> new CollaborationFollowV1(
|
||||
value.id(), value.userId(), value.reason(), value.notifyOnChange(), value.notifyOnComment(), value.mutedUntil(), name(names, value.userId()))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -159,33 +202,70 @@ final class CollaborationViews
|
||||
/*******************************************************************************
|
||||
** Project Share public fields.
|
||||
*******************************************************************************/
|
||||
static CollaborationWorkSchemas.CollaborationShareV1 share(com.kingsrook.qqq.backend.core.model.data.QRecord row)
|
||||
static CollaborationWorkSchemas.CollaborationShareV1 share(QRecord row) throws QException
|
||||
{
|
||||
return (new CollaborationWorkSchemas.CollaborationShareV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("fromUserId"), row.getValueString("toUserId"), row.getValueString("message"), row.getValueBoolean("recipientCouldRead"), row.getValueInstant("sentAt")));
|
||||
return (shares(List.of(row)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project shares, resolving sender and recipient names in one batch.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationWorkSchemas.CollaborationShareV1> shares(List<QRecord> rows) throws QException
|
||||
{
|
||||
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("fromUserId"), row.getValueString("toUserId"))).toArray(String[]::new));
|
||||
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationShareV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
row.getValueString("fromUserId"), row.getValueString("toUserId"), row.getValueString("message"), row.getValueBoolean("recipientCouldRead"), row.getValueInstant("sentAt"),
|
||||
name(names, row.getValueString("fromUserId")), name(names, row.getValueString("toUserId")))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project AccessRequest public fields.
|
||||
*******************************************************************************/
|
||||
static CollaborationWorkSchemas.CollaborationAccessRequestV1 accessRequest(com.kingsrook.qqq.backend.core.model.data.QRecord row)
|
||||
static CollaborationWorkSchemas.CollaborationAccessRequestV1 accessRequest(QRecord row) throws QException
|
||||
{
|
||||
return (new CollaborationWorkSchemas.CollaborationAccessRequestV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("requesterUserId"), row.getValueString("message"), row.getValueString("status"), row.getValueInstant("handledAt"), row.getValueString("note")));
|
||||
return (accessRequests(List.of(row)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project access requests, resolving requester and handler names in one batch.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationWorkSchemas.CollaborationAccessRequestV1> accessRequests(List<QRecord> rows) throws QException
|
||||
{
|
||||
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("requesterUserId"), row.getValueString("handledByUserId"))).toArray(String[]::new));
|
||||
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationAccessRequestV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
row.getValueString("requesterUserId"), row.getValueString("message"), row.getValueString("status"), row.getValueInstant("handledAt"), row.getValueString("note"),
|
||||
name(names, row.getValueString("requesterUserId")), name(names, row.getValueString("handledByUserId")))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project Task public fields.
|
||||
*******************************************************************************/
|
||||
static CollaborationWorkSchemas.CollaborationTaskV1 task(com.kingsrook.qqq.backend.core.model.data.QRecord row)
|
||||
static CollaborationWorkSchemas.CollaborationTaskV1 task(QRecord row) throws QException
|
||||
{
|
||||
return (new CollaborationWorkSchemas.CollaborationTaskV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("title"), row.getValueString("notes"), row.getValueString("assigneeUserId"), row.getValueInstant("dueAt"), row.getValueInstant("remindAt"), row.getValueString("status"), row.getValueString("createdByUserId"), row.getValueInstant("completedAt"), row.getValueString("completedByUserId")));
|
||||
return (tasks(List.of(row)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project tasks, resolving assignee, creator and completer names in one batch.
|
||||
*******************************************************************************/
|
||||
static List<CollaborationWorkSchemas.CollaborationTaskV1> tasks(List<QRecord> rows) throws QException
|
||||
{
|
||||
Map<String, String> names = names(rows.stream().flatMap(row -> Stream.of(row.getValueString("assigneeUserId"), row.getValueString("createdByUserId"),
|
||||
row.getValueString("completedByUserId"))).toArray(String[]::new));
|
||||
return (rows.stream().map(row -> new CollaborationWorkSchemas.CollaborationTaskV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
row.getValueString("title"), row.getValueString("notes"), row.getValueString("assigneeUserId"), row.getValueInstant("dueAt"), row.getValueInstant("remindAt"), row.getValueString("status"),
|
||||
row.getValueString("createdByUserId"), row.getValueInstant("completedAt"), row.getValueString("completedByUserId"),
|
||||
name(names, row.getValueString("assigneeUserId")), name(names, row.getValueString("createdByUserId")), name(names, row.getValueString("completedByUserId")))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Project Assignment public fields.
|
||||
*******************************************************************************/
|
||||
static CollaborationWorkSchemas.CollaborationAssignmentV1 assignment(com.kingsrook.qqq.backend.core.model.data.QRecord row)
|
||||
static CollaborationWorkSchemas.CollaborationAssignmentV1 assignment(QRecord row) throws QException
|
||||
{
|
||||
return (new CollaborationWorkSchemas.CollaborationAssignmentV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("ownerUserId"), row.getValueString("assigneeUserId")));
|
||||
Map<String, String> names = names(row.getValueString("ownerUserId"), row.getValueString("assigneeUserId"));
|
||||
return (new CollaborationWorkSchemas.CollaborationAssignmentV1(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
row.getValueString("ownerUserId"), row.getValueString("assigneeUserId"), name(names, row.getValueString("ownerUserId")), name(names, row.getValueString("assigneeUserId"))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -95,7 +95,7 @@ public final class CollaborationWorkSchemas
|
||||
/*******************************************************************************
|
||||
** Versioned Share body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationShareV1(Integer id, String tableName, String recordId, String fromUserId, String toUserId, String message, Boolean recipientCouldRead, Instant sentAt) implements ToSchema
|
||||
public record CollaborationShareV1(Integer id, String tableName, String recordId, String fromUserId, String toUserId, String message, Boolean recipientCouldRead, Instant sentAt, String fromDisplayName, String toDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
@@ -111,7 +111,9 @@ public final class CollaborationWorkSchemas
|
||||
Map.entry("toUserId", new Schema().withType(Type.STRING).withDescription("toUserId.").withNullable(true)),
|
||||
Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)),
|
||||
Map.entry("recipientCouldRead", new Schema().withType(Type.BOOLEAN).withDescription("recipientCouldRead.").withNullable(true)),
|
||||
Map.entry("sentAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("sentAt.").withNullable(true))))));
|
||||
Map.entry("sentAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("sentAt.").withNullable(true)),
|
||||
Map.entry("fromDisplayName", new Schema().withType(Type.STRING).withDescription("Sender display name.").withNullable(true)),
|
||||
Map.entry("toDisplayName", new Schema().withType(Type.STRING).withDescription("Recipient display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +153,7 @@ public final class CollaborationWorkSchemas
|
||||
/*******************************************************************************
|
||||
** Versioned AccessRequest body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAccessRequestV1(Integer id, String tableName, String recordId, String requesterUserId, String message, String status, Instant handledAt, String note) implements ToSchema
|
||||
public record CollaborationAccessRequestV1(Integer id, String tableName, String recordId, String requesterUserId, String message, String status, Instant handledAt, String note, String requesterDisplayName, String handledByDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
@@ -167,7 +169,9 @@ public final class CollaborationWorkSchemas
|
||||
Map.entry("message", new Schema().withType(Type.STRING).withDescription("message.").withNullable(true)),
|
||||
Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true)),
|
||||
Map.entry("handledAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("handledAt.").withNullable(true)),
|
||||
Map.entry("note", new Schema().withType(Type.STRING).withDescription("note.").withNullable(true))))));
|
||||
Map.entry("note", new Schema().withType(Type.STRING).withDescription("note.").withNullable(true)),
|
||||
Map.entry("requesterDisplayName", new Schema().withType(Type.STRING).withDescription("Requester display name.").withNullable(true)),
|
||||
Map.entry("handledByDisplayName", new Schema().withType(Type.STRING).withDescription("Display name of the person who handled the request.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +214,7 @@ public final class CollaborationWorkSchemas
|
||||
/*******************************************************************************
|
||||
** Versioned Task body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationTaskV1(Integer id, String tableName, String recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt, String status, String createdByUserId, Instant completedAt, String completedByUserId) implements ToSchema
|
||||
public record CollaborationTaskV1(Integer id, String tableName, String recordId, String title, String notes, String assigneeUserId, Instant dueAt, Instant remindAt, String status, String createdByUserId, Instant completedAt, String completedByUserId, String assigneeDisplayName, String createdByDisplayName, String completedByDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
@@ -230,7 +234,10 @@ public final class CollaborationWorkSchemas
|
||||
Map.entry("status", new Schema().withType(Type.STRING).withDescription("status.").withNullable(true)),
|
||||
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("createdByUserId.").withNullable(true)),
|
||||
Map.entry("completedAt", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("completedAt.").withNullable(true)),
|
||||
Map.entry("completedByUserId", new Schema().withType(Type.STRING).withDescription("completedByUserId.").withNullable(true))))));
|
||||
Map.entry("completedByUserId", new Schema().withType(Type.STRING).withDescription("completedByUserId.").withNullable(true)),
|
||||
Map.entry("assigneeDisplayName", new Schema().withType(Type.STRING).withDescription("Assignee display name.").withNullable(true)),
|
||||
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Creator display name.").withNullable(true)),
|
||||
Map.entry("completedByDisplayName", new Schema().withType(Type.STRING).withDescription("Display name of the person who completed the task.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +319,7 @@ public final class CollaborationWorkSchemas
|
||||
/*******************************************************************************
|
||||
** Versioned Assignment body.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAssignmentV1(Integer id, String tableName, String recordId, String ownerUserId, String assigneeUserId) implements ToSchema
|
||||
public record CollaborationAssignmentV1(Integer id, String tableName, String recordId, String ownerUserId, String assigneeUserId, String ownerDisplayName, String assigneeDisplayName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schema for generated clients.
|
||||
@@ -325,7 +332,9 @@ public final class CollaborationWorkSchemas
|
||||
Map.entry("tableName", new Schema().withType(Type.STRING).withDescription("tableName.").withNullable(true)),
|
||||
Map.entry("recordId", new Schema().withType(Type.STRING).withDescription("recordId.").withNullable(true)),
|
||||
Map.entry("ownerUserId", new Schema().withType(Type.STRING).withDescription("ownerUserId.").withNullable(true)),
|
||||
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true))))));
|
||||
Map.entry("assigneeUserId", new Schema().withType(Type.STRING).withDescription("assigneeUserId.").withNullable(true)),
|
||||
Map.entry("ownerDisplayName", new Schema().withType(Type.STRING).withDescription("Owner display name.").withNullable(true)),
|
||||
Map.entry("assigneeDisplayName", new Schema().withType(Type.STRING).withDescription("Assignee display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,18 +366,18 @@ public final class CollaborationWorkSchemas
|
||||
schemas.put("CollaborationLinkV1", new CollaborationLinkV1(null, null).toSchema());
|
||||
schemas.put("CollaborationShareRecipientV1", new CollaborationShareRecipientV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationSharePreviewV1", new CollaborationSharePreviewV1(null).toSchema());
|
||||
schemas.put("CollaborationShareV1", new CollaborationShareV1(null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationShareV1", new CollaborationShareV1(null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationShareListV1", new CollaborationShareListV1(null).toSchema());
|
||||
schemas.put("CollaborationShareRequestV1", new CollaborationShareRequestV1(null, null).toSchema());
|
||||
schemas.put("CollaborationAccessRequestV1", new CollaborationAccessRequestV1(null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationAccessRequestV1", new CollaborationAccessRequestV1(null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationAccessRequestCreateRequestV1", new CollaborationAccessRequestCreateRequestV1(null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationAccessRequestListV1", new CollaborationAccessRequestListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationTaskV1", new CollaborationTaskV1(null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTaskV1", new CollaborationTaskV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTaskCreateRequestV1", new CollaborationTaskCreateRequestV1(null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTaskPatchRequestV1", new CollaborationTaskPatchRequestV1(null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationTaskListV1", new CollaborationTaskListV1(null, null).toSchema());
|
||||
schemas.put("CollaborationAssignmentRequestV1", new CollaborationAssignmentRequestV1(null, null).toSchema());
|
||||
schemas.put("CollaborationAssignmentV1", new CollaborationAssignmentV1(null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationAssignmentV1", new CollaborationAssignmentV1(null, null, null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationMyWorkV1", new CollaborationMyWorkV1(null, null, null, null).toSchema());
|
||||
return (schemas);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,6 @@ public class CommentHistorySpec extends CollaborationJsonSpec
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = page(input, new CommentsService().history(id(input)));
|
||||
return (new CollaborationCommentHistoryV1(page.items().stream().map(CollaborationViews::commentRevision).toList(), page.nextCursor()));
|
||||
return (new CollaborationCommentHistoryV1(CollaborationViews.commentRevisions(page.items()), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,8 @@ public class CreateSharesSpec extends CollaborationJsonSpec
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var body = body(input, CollaborationWorkSchemas.CollaborationShareRequestV1.class);
|
||||
return (new CollaborationWorkSchemas.CollaborationShareListV1(new com.kingsrook.qbits.collaboration.sharing.SharingService()
|
||||
.share(table(input), pk(input), recipients(body.toUserIds()), body.message()).stream().map(CollaborationViews::share).toList()));
|
||||
return (new CollaborationWorkSchemas.CollaborationShareListV1(CollaborationViews.shares(new com.kingsrook.qbits.collaboration.sharing.SharingService()
|
||||
.share(table(input), pk(input), recipients(body.toUserIds()), body.message()))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,6 @@ public class GetFollowSpec extends CollaborationJsonSpec
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var state = new FollowService().state(table(input), pk(input));
|
||||
return (new CollaborationFollowStateV1(state.isPresent(), state.map(CollaborationViews::follow).orElse(null)));
|
||||
return (new CollaborationFollowStateV1(state.isPresent(), state.isPresent() ? CollaborationViews.follow(state.get()) : null));
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ public class GetMyWorkSpec extends CollaborationJsonSpec
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var summary = new com.kingsrook.qbits.collaboration.mywork.MyWorkService().summary();
|
||||
return (new CollaborationWorkSchemas.CollaborationMyWorkV1(summary.openTaskCount(), summary.tasks().stream().map(CollaborationViews::task).toList(),
|
||||
return (new CollaborationWorkSchemas.CollaborationMyWorkV1(summary.openTaskCount(), CollaborationViews.tasks(summary.tasks()),
|
||||
summary.assignedRecordsByTable(), summary.unreadMentionCount()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,6 @@ public class ListAttachmentsSpec extends CollaborationJsonSpec
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = page(input, new AttachmentsService().list(table(input), pk(input)));
|
||||
return (new CollaborationAttachmentListV1(page.items().stream().map(CollaborationViews::attachment).toList(), page.nextCursor()));
|
||||
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,6 @@ public class ListFollowersSpec extends CollaborationJsonSpec
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = page(input, new FollowService().followers(table(input), pk(input)));
|
||||
return (new CollaborationFollowerListV1(page.items().stream().map(CollaborationViews::follow).toList(), page.nextCursor()));
|
||||
return (new CollaborationFollowerListV1(CollaborationViews.follows(page.items()), page.nextCursor()));
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ public class ListMyAccessRequestsSpec extends CollaborationJsonSpec
|
||||
{
|
||||
var values = new com.kingsrook.qbits.collaboration.sharing.AccessRequestService().mine();
|
||||
var page = page(input, values);
|
||||
return (new CollaborationWorkSchemas.CollaborationAccessRequestListV1(page.items().stream().map(CollaborationViews::accessRequest).toList(), page.nextCursor()));
|
||||
return (new CollaborationWorkSchemas.CollaborationAccessRequestListV1(CollaborationViews.accessRequests(page.items()), page.nextCursor()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -65,7 +65,7 @@ public class ListMyTasksSpec extends CollaborationJsonSpec
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = new com.kingsrook.qbits.collaboration.tasks.TasksService().mine(input.getQueryParam("status"), limit(input), input.getQueryParam("cursor"));
|
||||
return (new CollaborationWorkSchemas.CollaborationTaskListV1(page.tasks().stream().map(CollaborationViews::task).toList(), page.nextCursor()));
|
||||
return (new CollaborationWorkSchemas.CollaborationTaskListV1(CollaborationViews.tasks(page.tasks()), page.nextCursor()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class ListTasksSpec extends CollaborationJsonSpec
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QBadRequestException("includeDone must be true or false"));
|
||||
}
|
||||
var page = page(input, new com.kingsrook.qbits.collaboration.tasks.TasksService().list(table(input), pk(input), Boolean.valueOf(includeDone)));
|
||||
return (new CollaborationWorkSchemas.CollaborationTaskListV1(page.items().stream().map(CollaborationViews::task).toList(), page.nextCursor()));
|
||||
return (new CollaborationWorkSchemas.CollaborationTaskListV1(CollaborationViews.tasks(page.items()), page.nextCursor()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,22 @@ package com.kingsrook.qbits.collaboration.app;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
|
||||
@@ -41,27 +48,44 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOp
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
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.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData.StatisticsGroupData.Statistic;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Collaboration app and its home. Counts use QQQ actions with the current
|
||||
** QContext session, preserving permissions and record security.
|
||||
** The Collaboration app and its home, built from standard QQQ widgets: a KPI
|
||||
** block and three full-width tables (tags, custom fields, who's here). Counts
|
||||
** use QQQ actions with the current QContext session, so permissions and record
|
||||
** security apply, and every block carries a real empty-state message. No colors
|
||||
** are specified, so the frontend theme (including dark mode) decides.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationHome
|
||||
{
|
||||
public static final String HOME_WIDGET = "collaborationHome";
|
||||
public static final String KPI_WIDGET = "collaborationKpis";
|
||||
public static final String TAGS_WIDGET = "collaborationTags";
|
||||
public static final String FIELDS_WIDGET = "collaborationCustomFields";
|
||||
public static final String PRESENCE_WIDGET = "collaborationWhosHere";
|
||||
public static final List<String> HOME_WIDGETS = List.of(KPI_WIDGET, TAGS_WIDGET, FIELDS_WIDGET, PRESENCE_WIDGET);
|
||||
|
||||
public static final String NOBODY_HERE = "Nobody else is here right now.";
|
||||
public static final String NO_TAGS = "No tags yet. Tags appear here once someone creates one on a record.";
|
||||
public static final String NO_FIELDS = "No custom fields yet. Define one under Custom fields.";
|
||||
public static final String PRESENCE_ADMIN_ONLY = "Who's here is visible to collaboration administrators.";
|
||||
|
||||
|
||||
|
||||
@@ -81,9 +105,9 @@ public final class CollaborationHome
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel("Collaboration").withIcon(new QIcon("forum"))
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.withWidgets(List.of(HOME_WIDGET))
|
||||
.withWidgets(HOME_WIDGETS)
|
||||
.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);
|
||||
}
|
||||
@@ -91,140 +115,235 @@ public final class CollaborationHome
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full-width HTML widget, following the reference Storage home renderer.
|
||||
** The four full-width home widgets, in display order.
|
||||
*******************************************************************************/
|
||||
public static QWidgetMetaData widget()
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false)
|
||||
return (List.of(
|
||||
widget(KPI_WIDGET, "Overview", WidgetType.MULTI_STATISTICS, KpiRenderer.class),
|
||||
widget(TAGS_WIDGET, "Tags", WidgetType.TABLE, TagsRenderer.class),
|
||||
widget(FIELDS_WIDGET, "Custom fields", WidgetType.TABLE, FieldsRenderer.class),
|
||||
widget(PRESENCE_WIDGET, "Who's here", WidgetType.TABLE, PresenceRenderer.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One full-width card-less widget guarded by the shared permission rules.
|
||||
*******************************************************************************/
|
||||
private static QWidgetMetaData widget(String name, String label, WidgetType type, Class<? extends AbstractWidgetRenderer> renderer)
|
||||
{
|
||||
return (new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType())
|
||||
.withCodeReference(new QCodeReference(renderer)).withGridColumns(12).withIsCard(false)
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Show persisted enabled overrides by scope type, and recent directory use.
|
||||
** These are settings counts, not a claim about resolved host-table features.
|
||||
** The KPI groups: conversations, work, files and custom fields.
|
||||
*******************************************************************************/
|
||||
public static String render(Instant now) throws QException
|
||||
public static MultiStatisticsData kpis(Instant now) throws QException
|
||||
{
|
||||
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
|
||||
StringBuilder html = new StringBuilder("<div style=\"display:flex;flex-direction:column;gap:1rem;width:100%;overflow-wrap:anywhere;\"><section><h3>Feature settings</h3>");
|
||||
long settings = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter());
|
||||
if(settings == 0)
|
||||
{
|
||||
html.append("<p>No feature settings yet: features follow the code defaults</p>");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<p>Enabled feature settings per scope:</p><ul>");
|
||||
for(String scope : List.of("INSTANCE", "APP", "TABLE"))
|
||||
{
|
||||
long enabled = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("scopeType", QCriteriaOperator.EQUALS, scope))
|
||||
.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)));
|
||||
html.append("<li>").append(scope).append(": ").append(enabled).append("</li>");
|
||||
}
|
||||
html.append("</ul><p>Feature settings take effect after saving.</p>");
|
||||
}
|
||||
long people = count(CollaborationTables.USER, new QQueryFilter());
|
||||
long recent = count(CollaborationTables.USER, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS)))
|
||||
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)));
|
||||
html.append("</section><section><h3>People</h3><p>People known: ").append(people)
|
||||
.append("</p><p>Active in the last 7 days: ").append(recent).append("</p>");
|
||||
if(people == 0)
|
||||
{
|
||||
html.append("<p>No people yet: the directory will be filled by collaboration calls when the feature services are installed.</p>");
|
||||
}
|
||||
html.append("</section>");
|
||||
personalSummary(html, CollaborationTables.FAVORITE, "Favorites");
|
||||
personalSummary(html, CollaborationTables.RECENT, "Recently viewed");
|
||||
attachmentSummary(html);
|
||||
tagSummary(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,
|
||||
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"))).append("</p></section>");
|
||||
html.append("<section><h3>Overdue tasks</h3><p>").append(count(CollaborationTables.TASK,
|
||||
new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN").withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now))).append("</p></section>");
|
||||
html.append("<section><h3>Shares this week</h3><p>").append(count(CollaborationTables.SHARE,
|
||||
new QQueryFilter().withCriteria("sentAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
|
||||
.withCriteria("sentAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))).append("</p></section>");
|
||||
html.append("<section><h3>Comments this week</h3><p>").append(count(CollaborationTables.COMMENT, new QQueryFilter()
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
|
||||
.withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("</p></section>");
|
||||
html.append("<section><h3>Open threads</h3><p>").append(count(CollaborationTables.COMMENT, new QQueryFilter()
|
||||
.withCriteria("threadId", QCriteriaOperator.IS_BLANK).withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK)
|
||||
.withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("</p></section>");
|
||||
if(QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN))
|
||||
{
|
||||
html.append("<section><h3>Followers</h3><p>").append(count(CollaborationTables.FOLLOW, new QQueryFilter())).append("</p></section>");
|
||||
}
|
||||
html.append("<p>Conversation totals are approximate where parent record locks require post-filtering.</p>");
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Caller-filtered vocabulary totals and five most used active tags.
|
||||
*******************************************************************************/
|
||||
private static void tagSummary(StringBuilder html) throws QException
|
||||
{
|
||||
QQueryFilter active = new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK);
|
||||
html.append("<section><h3>Tags</h3><p>Vocabulary size: ").append(count(CollaborationTables.TAG, active))
|
||||
.append("</p><p>Pending suggestions: ").append(count(CollaborationTables.TAG, new QQueryFilter()
|
||||
.withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK).withCriteria("approved", QCriteriaOperator.EQUALS, false)))
|
||||
.append("</p><ul>");
|
||||
com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput input = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(CollaborationTables.TAG)
|
||||
.withFilter(active.withCriteria("approved", QCriteriaOperator.EQUALS, true).withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(5));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
for(com.kingsrook.qqq.backend.core.model.data.QRecord tag : new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(input).getRecords())
|
||||
{
|
||||
html.append("<li>").append(tag.getValueString("label").replace("&", "&").replace("<", "<").replace(">", ">"))
|
||||
.append(": ").append(tag.getValueInteger("usageCount")).append("</li>");
|
||||
}
|
||||
html.append("</ul></section>");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Real table aggregates, restricted to readable parent tables. Parent locks
|
||||
** are post-filtered on rows, so these aggregate totals are approximate.
|
||||
*******************************************************************************/
|
||||
private static void attachmentSummary(StringBuilder html) throws QException
|
||||
{
|
||||
Aggregate count = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
|
||||
Instant weekAgo = now.minus(7, ChronoUnit.DAYS);
|
||||
int commentsThisWeek = count(CollaborationTables.COMMENT, new QQueryFilter()
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, weekAgo)
|
||||
.withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK));
|
||||
int openThreads = count(CollaborationTables.COMMENT, new QQueryFilter().withCriteria("threadId", QCriteriaOperator.IS_BLANK)
|
||||
.withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK));
|
||||
int openTasks = count(CollaborationTables.TASK, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"));
|
||||
int overdue = count(CollaborationTables.TASK, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN")
|
||||
.withCriteria("dueAt", QCriteriaOperator.LESS_THAN, now));
|
||||
int openRequests = count(CollaborationTables.ACCESS_REQUEST, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "OPEN"));
|
||||
int activeFields = count(CollaborationTables.FIELD_DEFINITION, new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
|
||||
Aggregate files = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
|
||||
Aggregate bytes = new Aggregate().withFieldName("sizeBytes").withOperator(AggregateOperator.SUM);
|
||||
AggregateInput input = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(count, bytes));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
List<AggregateResult> results = new AggregateAction().execute(input).getResults();
|
||||
Object rowCount = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(count);
|
||||
Object totalBytes = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(bytes);
|
||||
html.append("<section><h3>Attachments</h3><p>Attachments: ").append(rowCount)
|
||||
.append("</p><p>Total size (bytes): ").append(totalBytes == null ? 0 : totalBytes)
|
||||
.append("</p><p>Approximate totals: parent record locks are applied when browsing attachments.</p>");
|
||||
if("0".equals(String.valueOf(rowCount)))
|
||||
{
|
||||
html.append("<p>No attachments yet.</p>");
|
||||
}
|
||||
html.append("</section>");
|
||||
AggregateInput aggregateInput = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(files, bytes));
|
||||
aggregateInput.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
List<AggregateResult> results = new AggregateAction().execute(aggregateInput).getResults();
|
||||
int attachments = results.isEmpty() ? 0 : numberOf(results.getFirst().getAggregateValue(files)).intValue();
|
||||
long totalBytes = results.isEmpty() ? 0 : numberOf(results.getFirst().getAggregateValue(bytes)).longValue();
|
||||
String fileSubheader = attachments == 0 ? "No attachments yet" : humanSize(totalBytes) + " in total";
|
||||
|
||||
return (new MultiStatisticsData("Collaboration", List.of(
|
||||
new StatisticsGroupData("forum", null, "Conversations", "Last 7 days and open", List.of(
|
||||
new Statistic("Comments this week", commentsThisWeek, path(CollaborationTables.COMMENT)),
|
||||
new Statistic("Open threads", openThreads, path(CollaborationTables.COMMENT)))),
|
||||
new StatisticsGroupData("task_alt", null, "Work", openTasks == 0 && openRequests == 0 ? "Nothing waiting" : "Waiting on someone", List.of(
|
||||
new Statistic("Open tasks", openTasks, path(CollaborationTables.TASK)),
|
||||
new Statistic("Overdue tasks", overdue, path(CollaborationTables.TASK)),
|
||||
new Statistic("Open access requests", openRequests, path(CollaborationTables.ACCESS_REQUEST)))),
|
||||
new StatisticsGroupData("attach_file", null, "Attachments", fileSubheader, List.of(
|
||||
new Statistic("Attachments", attachments, path(CollaborationTables.ATTACHMENT)))),
|
||||
new StatisticsGroupData("tune", null, "Custom fields", activeFields == 0 ? "None active yet" : "Defined by administrators", List.of(
|
||||
new Statistic("Active custom fields", activeFields, path(CollaborationTables.FIELD_DEFINITION)))))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Aggregate rows and distinct owners without loading high-volume personal data.
|
||||
** Approved and pending tags, most used first: label, table label, records.
|
||||
*******************************************************************************/
|
||||
private static void personalSummary(StringBuilder html, String table, String label) throws QException
|
||||
public static TableData tags() throws QException
|
||||
{
|
||||
Aggregate rows = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
|
||||
Aggregate users = new Aggregate().withFieldName("userId").withOperator(AggregateOperator.COUNT_DISTINCT);
|
||||
AggregateInput input = new AggregateInput(table).withAggregates(List.of(rows, users));
|
||||
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
|
||||
QueryInput input = new QueryInput(CollaborationTables.TAG).withFilter(new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK)
|
||||
.withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(200));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
List<AggregateResult> results = new AggregateAction().execute(input).getResults();
|
||||
html.append("<section><h3>").append(label).append("</h3><p>Rows: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(rows))
|
||||
.append("</p><p>Distinct users: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(users)).append("</p></section>");
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord tag : new QueryAction().execute(input).getRecords())
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("label", tag.getValueString("label"));
|
||||
row.put("table", tableLabel(tag.getValueString("tableName")));
|
||||
row.put("records", tag.getValueInteger("usageCount") == null ? 0 : tag.getValueInteger("usageCount"));
|
||||
rows.add(row);
|
||||
}
|
||||
return (table("Tags", NO_TAGS, rows, column("Label", "label", "3fr", "left"), column("Table", "table", "2fr", "left"), column("Records", "records", "1fr", "right")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Custom field definitions: name, table, status and index state.
|
||||
*******************************************************************************/
|
||||
public static TableData fields() throws QException
|
||||
{
|
||||
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
|
||||
QueryInput input = new QueryInput(CollaborationTables.FIELD_DEFINITION).withFilter(new QQueryFilter()
|
||||
.withOrderBy(new QFilterOrderBy("tableName")).withOrderBy(new QFilterOrderBy("name")).withLimit(200));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord definition : new QueryAction().execute(input).getRecords())
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("name", definition.getValueString("name"));
|
||||
row.put("table", tableLabel(definition.getValueString("tableName")));
|
||||
row.put("status", friendly(definition.getValueString("status")));
|
||||
row.put("indexState", friendly(definition.getValueString("indexState")));
|
||||
rows.add(row);
|
||||
}
|
||||
return (table("Custom fields", NO_FIELDS, rows, column("Name", "name", "3fr", "left"), column("Table", "table", "2fr", "left"),
|
||||
column("Status", "status", "1fr", "left"), column("Index state", "indexState", "1fr", "left")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Live viewer counts per table. Only administrators see them (they cover
|
||||
** records the viewer may not be able to open); others get an explanation.
|
||||
*******************************************************************************/
|
||||
public static TableData presence() throws QException
|
||||
{
|
||||
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
|
||||
boolean admin = QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if(admin)
|
||||
{
|
||||
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("table", tableLabel(table));
|
||||
row.put("viewers", viewers);
|
||||
rows.add(row);
|
||||
});
|
||||
}
|
||||
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "3fr", "left"), column("People", "viewers", "1fr", "right")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Assemble a table widget whose empty state is a sentence, not a blank grid.
|
||||
*******************************************************************************/
|
||||
private static TableData table(String label, String emptyMessage, List<Map<String, Object>> rows, TableData.Column... columns)
|
||||
{
|
||||
return (new TableData(label, List.of(columns), rows).withNoRowsFoundHTML(emptyMessage).withRowsPerPage(10));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A plain column; the frontend styles it.
|
||||
*******************************************************************************/
|
||||
private static TableData.Column column(String header, String accessor, String width, String align)
|
||||
{
|
||||
return (new TableData.Column("default", header, accessor, width, align));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table's display label, "All tables" when there is none (global).
|
||||
*******************************************************************************/
|
||||
private static String tableLabel(String name)
|
||||
{
|
||||
if(name == null || name.isBlank())
|
||||
{
|
||||
return ("All tables");
|
||||
}
|
||||
QTableMetaData table = QContext.getQInstance().getTable(name);
|
||||
return (table == null || table.getLabel() == null ? name : table.getLabel());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ACTIVE becomes Active; a missing value becomes a dash.
|
||||
*******************************************************************************/
|
||||
private static String friendly(String value)
|
||||
{
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return ("-");
|
||||
}
|
||||
String lower = value.toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Binary sizes with one decimal, like 4.7 KB; whole bytes below 1 KB.
|
||||
*******************************************************************************/
|
||||
public static String humanSize(long bytes)
|
||||
{
|
||||
if(bytes < 1024)
|
||||
{
|
||||
return (bytes + " B");
|
||||
}
|
||||
String[] units = { "KB", "MB", "GB", "TB", "PB" };
|
||||
double value = bytes;
|
||||
int unit = -1;
|
||||
while(value >= 1024 && unit < units.length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return (String.format(Locale.ROOT, "%.1f %s", value, units[unit]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Aggregate values come back as any numeric type, or null for empty sets.
|
||||
*******************************************************************************/
|
||||
private static Number numberOf(Object value)
|
||||
{
|
||||
return (value instanceof Number number ? number : 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The path of a Collaboration table page, under the parent app when configured.
|
||||
*******************************************************************************/
|
||||
private static String path(String table)
|
||||
{
|
||||
CollaborationQBitConfig config = CollaborationRecords.config();
|
||||
String prefix = config.getParentAppName() == null ? "/" + config.getAppName() : "/" + config.getParentAppName() + "/" + config.getAppName();
|
||||
return (prefix + "/" + table);
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +351,7 @@ public final class CollaborationHome
|
||||
/*******************************************************************************
|
||||
** Count without materializing rows or substituting a privileged session.
|
||||
*******************************************************************************/
|
||||
private static long count(String table, QQueryFilter filter) throws QException
|
||||
private static int count(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
CountInput input = new CountInput(table).withFilter(filter);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
@@ -242,9 +361,9 @@ public final class CollaborationHome
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Widget renderer instantiated by QQQ.
|
||||
** KPI renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
public static class HomeRenderer extends AbstractWidgetRenderer
|
||||
public static class KpiRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render using the current session and time.
|
||||
@@ -252,7 +371,58 @@ public final class CollaborationHome
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, CollaborationHome.render(Instant.now()))));
|
||||
return (new RenderWidgetOutput(kpis(Instant.now())));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tags renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
public static class TagsRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render using the current session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(tags()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Custom fields renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
public static class FieldsRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render using the current session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(fields()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Presence renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
public static class PresenceRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render using the current session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(presence()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ public class AssignmentService
|
||||
if(!assigneeUserId.equals(PersonalRecords.currentUserId()))
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(assigneeUserId, "A record was assigned to you", null,
|
||||
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)));
|
||||
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)).withEvent(row.getValue("id"), PersonalRecords.currentUserId()));
|
||||
}
|
||||
}
|
||||
return (row);
|
||||
|
||||
@@ -45,14 +45,13 @@ public class AttachmentStorage
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The bucket's own upload cap, when object storage exposes it through its api
|
||||
** package. It does not yet (requested from qbit-object-storage), so this
|
||||
** returns Long.MAX_VALUE: collaboration enforces its configured per-table cap,
|
||||
** and object storage still enforces the bucket's cap when the object is put.
|
||||
** The bucket's own upload cap, from object storage's api package; Long.MAX_VALUE
|
||||
** when the bucket has no limit. Collaboration also enforces its per-table cap.
|
||||
*******************************************************************************/
|
||||
public long maxBytes(String name)
|
||||
{
|
||||
return (Long.MAX_VALUE);
|
||||
Long max = bucket(name).maxUploadBytes();
|
||||
return (max == null ? Long.MAX_VALUE : max);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -318,7 +318,8 @@ public class CommentsService
|
||||
private void notify(QRecord row, String user, CollaborationNotification.Category category, String title, Integer mentionId) throws QException
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(user, title, row.getValueString("body"), category,
|
||||
row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId"))),
|
||||
row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId")))
|
||||
.withEvent(mentionId == null ? row.getValue("id") : mentionId, PersonalRecords.currentUserId()),
|
||||
mentionId == null ? null : () -> CollaborationRecords.update(CollaborationTables.MENTION,
|
||||
new QRecord().withValue("id", mentionId).withValue("notifiedAt", Instant.now())));
|
||||
}
|
||||
|
||||
@@ -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(".")));
|
||||
}
|
||||
}
|
||||
+68
@@ -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()));
|
||||
}
|
||||
}
|
||||
+163
@@ -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);
|
||||
}
|
||||
}
|
||||
+110
@@ -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);
|
||||
}
|
||||
}
|
||||
+99
@@ -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."));
|
||||
}
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* 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.email;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier;
|
||||
import com.kingsrook.qbits.collaboration.people.PeopleService;
|
||||
import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker;
|
||||
import com.kingsrook.qbits.email.EmailRuntime;
|
||||
import com.kingsrook.qbits.email.outbox.EmailCategory;
|
||||
import com.kingsrook.qbits.email.outbox.EmailRequest;
|
||||
import com.kingsrook.qbits.email.outbox.EmailSendResult;
|
||||
import com.kingsrook.qbits.email.outbox.EmailService;
|
||||
import com.kingsrook.qbits.email.template.EmailTemplateDefinition;
|
||||
import com.kingsrook.qbits.email.template.EmailTemplates;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delivers mention, share and task notifications through qbit-email, one
|
||||
** NOTIFICATION message per recipient. This is the only class that references
|
||||
** qbit-email; Notifications reaches it only after checking that the email qbit
|
||||
** is loadable and produced in the instance.
|
||||
**
|
||||
** Emails never carry comment bodies, task titles or notes (they may hold PHI):
|
||||
** they say who did what on which record, with a link. The record label is
|
||||
** included only when the recipient may read the record and every label field is
|
||||
** fully accessible; otherwise a generic label is used.
|
||||
*******************************************************************************/
|
||||
public final class EmailCollaborationNotifier implements CollaborationNotifier
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(EmailCollaborationNotifier.class);
|
||||
|
||||
public static final String OWNER_QBIT = "qbit-collaboration";
|
||||
public static final String MENTION_TEMPLATE = "collaboration_mention";
|
||||
public static final String SHARE_TEMPLATE = "collaboration_share";
|
||||
public static final String TASK_REMINDER_TEMPLATE = "collaboration_task_reminder";
|
||||
|
||||
private static final String SAMPLE = "{\"actorName\":\"Alex Example\",\"recordLabel\":\"Order 1042\",\"link\":\"https://example.invalid/app/orders/1042\"}";
|
||||
private static final String OPEN_LINK = "{{#if link}}[Open the record]({{link}}){.button}{{/if}}";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the email qbit is produced in the given instance.
|
||||
*******************************************************************************/
|
||||
public static boolean isAvailable(QInstance qInstance)
|
||||
{
|
||||
return (EmailRuntime.config(qInstance) != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enqueue the email for a mention, share or task notification; follow digests
|
||||
** have no email template. Failures are logged by user id and never thrown, so
|
||||
** a mail problem cannot fail the collaboration action.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(CollaborationNotification n)
|
||||
{
|
||||
EmailTemplateDefinition template = templateFor(n.category());
|
||||
if(template == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
String address = address(n.recipientUserId());
|
||||
if(!StringUtils.hasContent(address))
|
||||
{
|
||||
LOG.info("Collaboration email skipped: no address for recipient", logPair("userId", n.recipientUserId()));
|
||||
return;
|
||||
}
|
||||
EmailTemplates.ensure(template);
|
||||
String eventId = n.sourceId() != null ? n.sourceId() : n.sourceRecordId();
|
||||
EmailRequest request = new EmailRequest().withTemplateName(template.getName()).withCategory(EmailCategory.NOTIFICATION).withTo(address)
|
||||
.withIdempotencyKey("collaboration:" + kind(n.category()) + ":" + eventId + ":" + n.recipientUserId())
|
||||
.withSender(OWNER_QBIT, "notify-" + kind(n.category()))
|
||||
.withVariable("actorName", actorName(n.actorUserId())).withVariable("recordLabel", recordLabel(n));
|
||||
if(n.sourceTableName() != null && n.sourceRecordId() != null)
|
||||
{
|
||||
request.withLinkedRecord(n.sourceTableName(), n.sourceRecordId());
|
||||
}
|
||||
if(n.link() != null && n.link().startsWith("http"))
|
||||
{
|
||||
request.withVariable("link", n.link());
|
||||
}
|
||||
EmailSendResult result = EmailService.send(request);
|
||||
if(result.status() != EmailSendResult.Status.QUEUED)
|
||||
{
|
||||
LOG.info("Collaboration email not queued", logPair("userId", n.recipientUserId()), logPair("status", result.status()),
|
||||
logPair("messageId", result.messageId()));
|
||||
}
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("Collaboration email failed", e, logPair("userId", n.recipientUserId()), logPair("category", n.category()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The recipient address from the configured resolver, else from the
|
||||
** collaboration people directory.
|
||||
*******************************************************************************/
|
||||
private String address(String userId) throws QException
|
||||
{
|
||||
Function<String, String> resolver = CollaborationRecords.config().getRecipientEmailResolver();
|
||||
if(resolver != null)
|
||||
{
|
||||
return (resolver.apply(userId));
|
||||
}
|
||||
List<QRecord> rows = CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, userId));
|
||||
return (rows.isEmpty() ? null : rows.getFirst().getValueString("email"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The person's display name, or a generic word when the actor is unknown.
|
||||
*******************************************************************************/
|
||||
private String actorName(String actorUserId) throws QException
|
||||
{
|
||||
return (actorUserId == null ? "Someone" : new PeopleService().displayName(actorUserId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record label when the recipient may see it, else a generic phrase.
|
||||
*******************************************************************************/
|
||||
private String recordLabel(CollaborationNotification n) throws QException
|
||||
{
|
||||
if(n.sourceTableName() == null)
|
||||
{
|
||||
return ("a record");
|
||||
}
|
||||
return (new CollaborationRecordAccessChecker().recordLabel(n.recipientUserId(), n.sourceTableName(), n.sourceRecordId())
|
||||
.orElse("a record in " + QContext.getQInstance().getTable(n.sourceTableName()).getLabel()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Idempotency kind for a category.
|
||||
*******************************************************************************/
|
||||
private static String kind(CollaborationNotification.Category category)
|
||||
{
|
||||
return (switch(category)
|
||||
{
|
||||
case MENTION -> "mention";
|
||||
case SHARE -> "share";
|
||||
default -> "task";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped template for a category, or null when none exists.
|
||||
*******************************************************************************/
|
||||
static EmailTemplateDefinition templateFor(CollaborationNotification.Category category)
|
||||
{
|
||||
return (switch(category)
|
||||
{
|
||||
case MENTION -> mentionTemplate();
|
||||
case SHARE -> shareTemplate();
|
||||
case TASK -> taskReminderTemplate();
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared definition: variable schema and ownership. Existing templates, and
|
||||
** people's edits, are never touched. Labels are CONFIDENTIAL, not PII or PHI:
|
||||
** the template needs no PHI approval because labels are passed only when the
|
||||
** record label fields are fully accessible.
|
||||
*******************************************************************************/
|
||||
private static EmailTemplateDefinition definition(String name, String label, String subject, String body)
|
||||
{
|
||||
return (new EmailTemplateDefinition().withName(name).withLabel(label).withCategory(EmailCategory.NOTIFICATION).withKind("MARKDOWN")
|
||||
.withOwnerQbit(OWNER_QBIT).withSubject(subject).withBody(body)
|
||||
.withVariable("actorName", "Person who acted", "STRING", true, "CONFIDENTIAL")
|
||||
.withVariable("recordLabel", "Record label", "STRING", true, "CONFIDENTIAL")
|
||||
.withVariable("link", "Link to the record", "URL", false, "PUBLIC")
|
||||
.withSampleVariables(SAMPLE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Someone mentioned the recipient.
|
||||
*******************************************************************************/
|
||||
static EmailTemplateDefinition mentionTemplate()
|
||||
{
|
||||
return (definition(MENTION_TEMPLATE, "Collaboration mention", "{{actorName}} mentioned you on {{recordLabel}}",
|
||||
"**{{actorName}}** mentioned you on {{recordLabel}}.\n\n" + OPEN_LINK));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Someone shared a record with the recipient.
|
||||
*******************************************************************************/
|
||||
static EmailTemplateDefinition shareTemplate()
|
||||
{
|
||||
return (definition(SHARE_TEMPLATE, "Collaboration share", "{{actorName}} shared {{recordLabel}} with you",
|
||||
"**{{actorName}}** shared {{recordLabel}} with you.\n\n" + OPEN_LINK));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A task assigned to the recipient was assigned, is due, or is overdue.
|
||||
*******************************************************************************/
|
||||
static EmailTemplateDefinition taskReminderTemplate()
|
||||
{
|
||||
return (definition(TASK_REMINDER_TEMPLATE, "Collaboration task reminder", "A task on {{recordLabel}} needs your attention",
|
||||
"A task assigned to you on {{recordLabel}} needs your attention.\n\n" + OPEN_LINK));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -120,7 +132,7 @@ public final class CollaborationTables
|
||||
enumSource(FEATURE, "ATTACHMENTS", "TAGS", "CUSTOM_FIELDS", "COMMENTS", "PRESENCE", "SHARING",
|
||||
"FAVORITES", "FOLLOW", "TIMELINE", "RECENTS", "TASKS", "ASSIGNMENT"),
|
||||
enumSource(PERMISSION_MODE, "EDIT_OR_GRANTED", "GRANTED_ONLY"),
|
||||
QPossibleValueSource.newForTable(FEATURE_SETTING), QPossibleValueSource.newForTable(USER)));
|
||||
QPossibleValueSource.newForTable(FEATURE_SETTING), QPossibleValueSource.newForTable(USER).withOverrideIdField("userId")));
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
@@ -226,7 +239,7 @@ public final class CollaborationTables
|
||||
.withRecordSecurityLock(CollaborationSecurity.ownerLock("userId"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE))
|
||||
.withRecordLabelFormatAndFields("%s", "label")
|
||||
.withField(string("userId", "User ID", 255).withIsRequired(true))
|
||||
.withField(user("userId", "User ID").withIsRequired(true))
|
||||
.withField(string("targetType", "Target Type", 20).withIsRequired(true).withPossibleValueSourceName(TARGET_TYPE))
|
||||
.withField(string("targetName", "Target Name", 255).withIsRequired(true))
|
||||
.withField(string("recordId", "Record ID", 255))
|
||||
@@ -238,6 +251,15 @@ public final class CollaborationTables
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user-id string shown by display name wherever the table is browsed.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData user(String name, String label)
|
||||
{
|
||||
return (string(name, label, 255).withPossibleValueSourceName(USER));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded string field.
|
||||
*******************************************************************************/
|
||||
@@ -267,7 +289,7 @@ public final class CollaborationTables
|
||||
.withField(string("storageKey", "Storage Key", 1024).withIsRequired(true))
|
||||
.withField(string("thumbnailKey", "Thumbnail Key", 1024))
|
||||
.withField(new QFieldMetaData("description", QFieldType.TEXT).withLabel("Description"))
|
||||
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
|
||||
.withField(user("createdByUserId", "Created By").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("parentDeleted", QFieldType.BOOLEAN).withLabel("Parent Deleted").withIsRequired(true).withDefaultValue(false))
|
||||
.withVirtualField(new QVirtualFieldMetaData("previewUrl", QFieldType.STRING).withLabel("Preview").withIsEditable(false).withIsQuerySelectable(true)
|
||||
.withFieldAdornment(AdornmentType.FilePreviewValues.newFieldAdornment()
|
||||
@@ -309,28 +331,28 @@ public final class CollaborationTables
|
||||
.withField(new QFieldMetaData("body", QFieldType.TEXT).withMaxLength(20000))
|
||||
// TODO: add .withDependsOn("tableName") when QQQ core ships contingent dependsOn (docs/CONTINGENT-POSSIBLE-VALUES.md)
|
||||
.withField(string("fieldName", "Field", 128).withPossibleValueSourceName(RECORD_FIELD_NAME))
|
||||
.withField(string("authorUserId", "Author", 255).withIsRequired(true))
|
||||
.withField(user("authorUserId", "Author").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("lastActivityAt", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("editedAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("resolvedAt", QFieldType.DATE_TIME))
|
||||
.withField(string("resolvedByUserId", "Resolved By", 255))
|
||||
.withField(user("resolvedByUserId", "Resolved By"))
|
||||
.withField(new QFieldMetaData("deletedAt", QFieldType.DATE_TIME))
|
||||
.withField(string("deletedByUserId", "Deleted By", 255))
|
||||
.withField(user("deletedByUserId", "Deleted By"))
|
||||
.withRecordLabelFormatAndFields("Comment %s", "id");
|
||||
QTableMetaData revisions = referenced(config, COMMENT_REVISION, "Comment History", "history")
|
||||
.withField(new QFieldMetaData("commentId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(COMMENT))
|
||||
.withField(new QFieldMetaData("body", QFieldType.TEXT))
|
||||
.withField(new QFieldMetaData("editedAt", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
.withField(string("editedByUserId", "Edited By", 255));
|
||||
.withField(user("editedByUserId", "Edited By"));
|
||||
QTableMetaData mentions = referenced(config, MENTION, "Mentions", "alternate_email")
|
||||
.withField(new QFieldMetaData("commentId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(COMMENT))
|
||||
.withField(string("mentionedUserId", "Mentioned User", 255).withIsRequired(true))
|
||||
.withField(user("mentionedUserId", "Mentioned User").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("visible", QFieldType.BOOLEAN).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("readAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("notifiedAt", QFieldType.DATE_TIME))
|
||||
.withUniqueKey(new UniqueKey("commentId", "mentionedUserId"));
|
||||
QTableMetaData follows = referenced(config, FOLLOW, "Followers", "notifications")
|
||||
.withField(string("userId", "User", 255).withIsRequired(true))
|
||||
.withField(user("userId", "User").withIsRequired(true))
|
||||
.withField(string("reason", "Reason", 32).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("notifyOnChange", QFieldType.BOOLEAN).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("notifyOnComment", QFieldType.BOOLEAN).withIsRequired(true))
|
||||
@@ -340,13 +362,13 @@ public final class CollaborationTables
|
||||
.withoutCapabilities(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT,
|
||||
com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE)
|
||||
.withField(new QFieldMetaData("at", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
.withField(string("actorUserId", "Actor", 255))
|
||||
.withField(user("actorUserId", "Actor"))
|
||||
.withField(string("type", "Type", 64).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("summary", QFieldType.TEXT))
|
||||
.withField(string("refTableName", "Reference Table", 128))
|
||||
.withField(string("refId", "Reference ID", 64));
|
||||
QTableMetaData digests = referenced(config, FOLLOW_DIGEST, "Follow Digests", "notifications")
|
||||
.withField(string("userId", "Recipient", 255).withIsRequired(true))
|
||||
.withField(user("userId", "Recipient").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("dueAt", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("changes", QFieldType.TEXT))
|
||||
.withUniqueKey(new UniqueKey("tableName", "recordId", "userId"));
|
||||
@@ -398,7 +420,7 @@ public final class CollaborationTables
|
||||
.withField(string("tableName", "Table", 128))
|
||||
.withField(new QFieldMetaData("approved", QFieldType.BOOLEAN).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("description", QFieldType.TEXT))
|
||||
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
|
||||
.withField(user("createdByUserId", "Created By").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("usageCount", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(0))
|
||||
.withField(new QFieldMetaData("mergedIntoTagId", QFieldType.INTEGER).withPossibleValueSourceName(TAG))
|
||||
.withUniqueKey(new UniqueKey("tableName", "name"))
|
||||
@@ -408,7 +430,7 @@ public final class CollaborationTables
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
|
||||
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.security.CollaborationRowSecurity.class)))
|
||||
.withField(new QFieldMetaData("tagId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(TAG))
|
||||
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
|
||||
.withField(user("createdByUserId", "Created By").withIsRequired(true))
|
||||
.withUniqueKey(new UniqueKey("tableName", "recordId", "tagId"))
|
||||
.withSection(new QFieldSection("details", new QIcon("label"), Tier.T1, List.of("id", "tagId", "createdByUserId")).withLabel("Details"));
|
||||
for(QTableMetaData table : List.of(tags, links))
|
||||
@@ -424,8 +446,8 @@ public final class CollaborationTables
|
||||
private static List<QTableMetaData> workTables(CollaborationQBitConfig config)
|
||||
{
|
||||
QTableMetaData shares = referenced(config, SHARE, "Shares", "share")
|
||||
.withField(string("fromUserId", "From", 255).withIsRequired(true))
|
||||
.withField(string("toUserId", "To", 255).withIsRequired(true))
|
||||
.withField(user("fromUserId", "From").withIsRequired(true))
|
||||
.withField(user("toUserId", "To").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000))
|
||||
.withField(new QFieldMetaData("recipientCouldRead", QFieldType.BOOLEAN))
|
||||
.withField(new QFieldMetaData("sentAt", QFieldType.DATE_TIME).withIsRequired(true))
|
||||
@@ -438,29 +460,29 @@ public final class CollaborationTables
|
||||
.withRecordLabelFormatAndFields("Access request %s", "id")
|
||||
.withField(string("tableName", "Table", 128).withIsRequired(true))
|
||||
.withField(string("recordId", "Record ID", 64).withIsRequired(true))
|
||||
.withField(string("requesterUserId", "Requester", 255).withIsRequired(true))
|
||||
.withField(user("requesterUserId", "Requester").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("shareId", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("message", QFieldType.TEXT).withMaxLength(2000))
|
||||
.withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_access_request_status"))
|
||||
.withField(string("handledByUserId", "Handled By", 255))
|
||||
.withField(user("handledByUserId", "Handled By"))
|
||||
.withField(new QFieldMetaData("handledAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("note", QFieldType.TEXT).withMaxLength(2000));
|
||||
QTableMetaData tasks = referenced(config, TASK, "Tasks", "task_alt")
|
||||
.withRecordLabelFormatAndFields("%s", "title").withSearchFields(List.of("title"))
|
||||
.withField(string("title", "Title", 200).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("notes", QFieldType.TEXT))
|
||||
.withField(string("assigneeUserId", "Assignee", 255))
|
||||
.withField(user("assigneeUserId", "Assignee"))
|
||||
.withField(new QFieldMetaData("dueAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("remindAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("remindedAt", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("overdueNotifiedAt", QFieldType.DATE_TIME))
|
||||
.withField(string("status", "Status", 20).withIsRequired(true).withPossibleValueSourceName("collaboration_task_status"))
|
||||
.withField(new QFieldMetaData("completedAt", QFieldType.DATE_TIME))
|
||||
.withField(string("completedByUserId", "Completed By", 255))
|
||||
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true));
|
||||
.withField(user("completedByUserId", "Completed By"))
|
||||
.withField(user("createdByUserId", "Created By").withIsRequired(true));
|
||||
QTableMetaData assignments = referenced(config, RECORD_ASSIGNMENT, "Record Owners", "assignment_ind")
|
||||
.withField(string("ownerUserId", "Owner", 255))
|
||||
.withField(string("assigneeUserId", "Assignee", 255))
|
||||
.withField(user("ownerUserId", "Owner"))
|
||||
.withField(user("assigneeUserId", "Assignee"))
|
||||
.withUniqueKey(new UniqueKey("tableName", "recordId"));
|
||||
List<QTableMetaData> tables = List.of(shares, requests, tasks, assignments);
|
||||
for(QTableMetaData table : tables)
|
||||
@@ -473,4 +495,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(user("createdByUserId", "Created by").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);
|
||||
|
||||
+26
-1
@@ -24,10 +24,35 @@ package com.kingsrook.qbits.collaboration.notify;
|
||||
|
||||
/*******************************************************************************
|
||||
** Transport-neutral notification. Body is markdown; link is a record href.
|
||||
** sourceId identifies the event (mention, share, task or assignment row) so a
|
||||
** transport can de-duplicate; actorUserId is the person who caused it. Both are
|
||||
** optional.
|
||||
*******************************************************************************/
|
||||
public record CollaborationNotification(String recipientUserId, String title, String body, Category category,
|
||||
String sourceTableName, String sourceRecordId, String link)
|
||||
String sourceTableName, String sourceRecordId, String link, String sourceId, String actorUserId)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Notification without an event id or actor.
|
||||
*******************************************************************************/
|
||||
public CollaborationNotification(String recipientUserId, String title, String body, Category category,
|
||||
String sourceTableName, String sourceRecordId, String link)
|
||||
{
|
||||
this(recipientUserId, title, body, category, sourceTableName, sourceRecordId, link, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy carrying the event id and actor.
|
||||
*******************************************************************************/
|
||||
public CollaborationNotification withEvent(Object eventId, String actor)
|
||||
{
|
||||
return (new CollaborationNotification(recipientUserId, title, body, category, sourceTableName, sourceRecordId, link,
|
||||
eventId == null ? null : String.valueOf(eventId), actor));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Categories supported by the collaboration notification boundary.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -22,11 +22,13 @@
|
||||
package com.kingsrook.qbits.collaboration.notify;
|
||||
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.email.EmailCollaborationNotifier;
|
||||
import com.kingsrook.qbits.collaboration.security.CollaborationRecordAccessChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
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.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeVoidVoidMethod;
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -99,7 +101,7 @@ public final class Notifications
|
||||
{
|
||||
if(!checkParent || new CollaborationRecordAccessChecker().canRead(notification.recipientUserId(), notification.sourceTableName(), notification.sourceRecordId()).orElse(false))
|
||||
{
|
||||
CollaborationNotifier notifier = QCodeLoader.getAdHoc(CollaborationNotifier.class, CollaborationRecords.config().getNotifier());
|
||||
CollaborationNotifier notifier = notifier();
|
||||
notifier.notify(notification);
|
||||
if(!(notifier instanceof NoOpNotifier))
|
||||
{
|
||||
@@ -121,6 +123,41 @@ public final class Notifications
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured notifier; with none configured, email through qbit-email when
|
||||
** it is on the classpath and produced in this instance, else no transport.
|
||||
*******************************************************************************/
|
||||
private static CollaborationNotifier notifier() throws QException
|
||||
{
|
||||
QCodeReference configured = CollaborationRecords.config().getNotifier();
|
||||
if(configured != null)
|
||||
{
|
||||
return (QCodeLoader.getAdHoc(CollaborationNotifier.class, configured));
|
||||
}
|
||||
if(emailAvailable())
|
||||
{
|
||||
return (new EmailCollaborationNotifier());
|
||||
}
|
||||
return (new NoOpNotifier());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** qbit-email is optional: the adapter is touched only after the class check
|
||||
** passes and the email qbit is produced in the current instance.
|
||||
*******************************************************************************/
|
||||
private static boolean emailAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qbits.email.outbox.EmailService");
|
||||
return (EmailCollaborationNotifier.isAvailable(QContext.getQInstance()));
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Relative canonical frontend link with separately encoded path segments.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -109,10 +109,11 @@ public class PeopleService
|
||||
|
||||
/*******************************************************************************
|
||||
** Rank participants before limiting, with bounded backend queries for both groups.
|
||||
** The caller never appears in the results: nobody shares with or mentions themself.
|
||||
*******************************************************************************/
|
||||
public List<PersonEntry> search(String q, Integer limit, Set<String> preferredUserIds) throws QException
|
||||
{
|
||||
PersonalRecords.currentUserId();
|
||||
String me = PersonalRecords.currentUserId();
|
||||
if(limit == null || limit < 1 || limit > 100)
|
||||
{
|
||||
throw (new QBadRequestException("Limit must be between 1 and 100"));
|
||||
@@ -120,12 +121,12 @@ public class PeopleService
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
if(!preferredUserIds.isEmpty())
|
||||
{
|
||||
rows.addAll(CollaborationRecords.query(CollaborationTables.USER, searchFilter(q, limit)
|
||||
rows.addAll(CollaborationRecords.query(CollaborationTables.USER, searchFilter(q, limit, me)
|
||||
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferredUserIds))));
|
||||
}
|
||||
if(rows.size() < limit)
|
||||
{
|
||||
QQueryFilter other = searchFilter(q, limit - rows.size());
|
||||
QQueryFilter other = searchFilter(q, limit - rows.size(), me);
|
||||
if(!preferredUserIds.isEmpty())
|
||||
{
|
||||
other.withCriteria("userId", QCriteriaOperator.NOT_IN, new ArrayList<>(preferredUserIds));
|
||||
@@ -139,9 +140,10 @@ public class PeopleService
|
||||
/*******************************************************************************
|
||||
** Keep active and group restrictions outside the display-name/email OR.
|
||||
*******************************************************************************/
|
||||
private QQueryFilter searchFilter(String q, Integer limit)
|
||||
private QQueryFilter searchFilter(String q, Integer limit, String excludedUserId)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("active", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("userId", QCriteriaOperator.NOT_EQUALS, excludedUserId)
|
||||
.withOrderBy(new QFilterOrderBy("displayName")).withOrderBy(new QFilterOrderBy("userId")).withLimit(limit);
|
||||
if(q != null && !q.isBlank())
|
||||
{
|
||||
@@ -161,6 +163,37 @@ public class PeopleService
|
||||
return (rows.isEmpty() || rows.getFirst().getValueString("displayName") == null ? userId : rows.getFirst().getValueString("displayName"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve many display names with one directory query. Unknown ids map to
|
||||
** themselves, so a caller always has something readable to show.
|
||||
*******************************************************************************/
|
||||
public java.util.Map<String, String> displayNames(java.util.Collection<String> userIds) throws QException
|
||||
{
|
||||
java.util.Map<String, String> names = new java.util.LinkedHashMap<>();
|
||||
Set<String> ids = new java.util.LinkedHashSet<>();
|
||||
for(String id : userIds)
|
||||
{
|
||||
if(id != null && !id.isBlank())
|
||||
{
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return (names);
|
||||
}
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.USER, new QQueryFilter().withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(ids))))
|
||||
{
|
||||
String name = row.getValueString("displayName");
|
||||
if(name != null && !name.isBlank())
|
||||
{
|
||||
names.put(row.getValueString("userId"), name);
|
||||
}
|
||||
}
|
||||
ids.forEach(id -> names.putIfAbsent(id, id));
|
||||
return (names);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up one active person with the same signed-in boundary as search.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
+59
@@ -27,10 +27,14 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
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.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -82,4 +86,59 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
|
||||
});
|
||||
return (Optional.of(result.get()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The record label as the recipient may see it: empty unless a session for the
|
||||
** recipient exists, the record is readable, and every label field is fully
|
||||
** accessible (FieldAccessHelper.isRecordLabelFullAccess, absent in older QQQ
|
||||
** versions, which means no label).
|
||||
*******************************************************************************/
|
||||
public Optional<String> recordLabel(String userId, String table, Serializable recordId) throws QException
|
||||
{
|
||||
CollaborationQBitConfig config = CollaborationRecords.config();
|
||||
if(config.getUserSessionProvider() == null || table == null || recordId == null)
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
Optional<QSession> session = QCodeLoader.getAdHoc(com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface.class, config.getUserSessionProvider()).sessionForUser(userId);
|
||||
if(session.isEmpty() || session.get().getUser() == null || !userId.equals(session.get().getUser().getIdReference()))
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
AtomicReference<String> result = new AtomicReference<>();
|
||||
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), session.get()), () ->
|
||||
{
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireVisibleParent(table, recordId);
|
||||
if(labelFullyAccessible(table))
|
||||
{
|
||||
GetInput input = new GetInput(table).withPrimaryKey(recordId).withShouldGenerateDisplayValues(true);
|
||||
QRecord record = new GetAction().executeForRecord(input);
|
||||
result.set(record == null ? null : record.getRecordLabel());
|
||||
}
|
||||
}
|
||||
catch(QNotFoundException expected)
|
||||
{
|
||||
// Not readable by the recipient: no label.
|
||||
}
|
||||
});
|
||||
return (Optional.ofNullable(result.get()).filter(label -> !label.isBlank()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reflective guard: the helper only exists in QQQ versions with secured data.
|
||||
*******************************************************************************/
|
||||
private static boolean labelFullyAccessible(String table)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> helper = Class.forName("com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper");
|
||||
return (Boolean.TRUE.equals(helper.getMethod("isRecordLabelFullAccess", QTableMetaData.class).invoke(null, QContext.getQInstance().getTable(table))));
|
||||
}
|
||||
catch(ReflectiveOperationException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ public class SharingService
|
||||
if(Boolean.TRUE.equals(recipient.canRead()))
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared " + link.label() + " with you", message,
|
||||
CollaborationNotification.Category.SHARE, table, id, link.url()));
|
||||
CollaborationNotification.Category.SHARE, table, id, link.url()).withEvent(row.getValue("id"), from));
|
||||
new FollowService().ensureFollower(recipient.userId(), table, id, "SHARED");
|
||||
}
|
||||
else
|
||||
@@ -106,7 +106,7 @@ public class SharingService
|
||||
Notifications.sendAccessNotice(new CollaborationNotification(recipient.userId(), name + " wants to share a record in "
|
||||
+ QContext.getQInstance().getTable(table).getLabel() + " with you", "Request access to open this record.",
|
||||
CollaborationNotification.Category.SHARE, null, null, "/app/collaboration_access_request/create?tableName="
|
||||
+ TargetResolver.encode(table) + "&recordId=" + TargetResolver.encode(id) + "&shareId=" + row.getValueString("id")));
|
||||
+ TargetResolver.encode(table) + "&recordId=" + TargetResolver.encode(id) + "&shareId=" + row.getValueString("id")).withEvent(row.getValue("id"), from));
|
||||
}
|
||||
ActivityWriter.write(table, id, "SHARED", "Record shared", CollaborationTables.SHARE, row.getValueString("id"));
|
||||
result.add(row);
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.collaboration.tags;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
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.SearchTermKind;
|
||||
@@ -71,7 +72,7 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Numeric values are picker IDs; strings are exact normalized vocabulary names.
|
||||
** Numeric values (or numeric strings naming an existing tag) are picker IDs; other strings are exact normalized names.
|
||||
*******************************************************************************/
|
||||
private QQueryFilter membership(QTableMetaData table, List<Serializable> values, boolean negate, boolean any) throws QException
|
||||
{
|
||||
@@ -90,8 +91,8 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac
|
||||
{
|
||||
throw (new QUserFacingException("Tags cannot contain null values"));
|
||||
}
|
||||
choices.withCriteria(value instanceof Number ? "id" : "name", QCriteriaOperator.EQUALS,
|
||||
value instanceof Number ? value : TagsService.normalize(value.toString()));
|
||||
Integer id = tagId(table, value);
|
||||
choices.withCriteria(id != null ? "id" : "name", QCriteriaOperator.EQUALS, id != null ? id : TagsService.normalize(value.toString()));
|
||||
}
|
||||
tags.withSubFilter(choices);
|
||||
links.withCriteria("tagId", QCriteriaOperator.IN, new QSubQuery(CollaborationTables.TAG, "id", tags));
|
||||
@@ -100,6 +101,30 @@ public class TagCriteriaExpander implements VirtualFieldCriteriaExpanderInterfac
|
||||
new QSubQuery(CollaborationTables.RECORD_TAG, "recordId", links)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A value is a picker id when it is a Number (any JSON numeric type) or a String
|
||||
** holding an integer that matches an existing tag; otherwise it is a name (null).
|
||||
*******************************************************************************/
|
||||
private Integer tagId(QTableMetaData table, Serializable value) throws QException
|
||||
{
|
||||
if(value instanceof Number number)
|
||||
{
|
||||
return (number.intValue());
|
||||
}
|
||||
String text = value.toString().strip();
|
||||
try
|
||||
{
|
||||
int id = Integer.parseInt(text);
|
||||
boolean exists = !CollaborationRecords.query(CollaborationTables.TAG, TagsService.vocabularyFilter(table.getName())
|
||||
.withCriteria("id", QCriteriaOperator.EQUALS, id)).isEmpty();
|
||||
return (exists ? id : null);
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only live associations on this parent table participate.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public class TaskReminders
|
||||
String id = row.getValueString("recordId");
|
||||
Notifications.send(new CollaborationNotification(row.getValueString("assigneeUserId"),
|
||||
(dueField.equals("remindAt") ? "Task reminder: " : "Task overdue: ") + row.getValueString("title"), row.getValueString("notes"),
|
||||
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)),
|
||||
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)).withEvent(row.getValue("id") + ":" + dueField, null),
|
||||
() -> CollaborationRecords.update(CollaborationTables.TASK, new QRecord().withValue("id", row.getValue("id")).withValue(receipt, now)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +266,8 @@ public class TasksService
|
||||
if(!user.equals(PersonalRecords.currentUserId()))
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(user, "Task assigned: " + row.getValueString("title"), row.getValueString("notes"),
|
||||
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id)));
|
||||
CollaborationNotification.Category.TASK, table, id, Notifications.link(table, id))
|
||||
.withEvent(row.getValue("id"), PersonalRecords.currentUserId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
*******************************************************************************/
|
||||
public class TimelineService
|
||||
{
|
||||
private static final int MAX_FIELDS_IN_SUMMARY = 4;
|
||||
|
||||
/*******************************************************************************
|
||||
** Types accepts AUDIT/ACTIVITY or individual collaboration activity type names.
|
||||
*******************************************************************************/
|
||||
@@ -154,8 +156,10 @@ public class TimelineService
|
||||
List<TimelineItem> result = new ArrayList<>();
|
||||
for(QRecord row : headers)
|
||||
{
|
||||
result.add(new TimelineItem("AUDIT", row.getValueLong("id"), row.getValueInstant("timestamp"), null, actors.get(row.getValueInteger("auditUserId")),
|
||||
row.getValueString("message"), List.copyOf(details.getOrDefault(row.getValueLong("id"), List.of())), AuditsMetaDataProvider.TABLE_NAME_AUDIT, row.getValueString("id")));
|
||||
String actor = actors.get(row.getValueInteger("auditUserId"));
|
||||
List<TimelineDetail> rowDetails = List.copyOf(details.getOrDefault(row.getValueLong("id"), List.of()));
|
||||
result.add(new TimelineItem("AUDIT", row.getValueLong("id"), row.getValueInstant("timestamp"), null, actor,
|
||||
friendlySummary(row.getValueString("message"), actor, rowDetails, personalized), rowDetails, AuditsMetaDataProvider.TABLE_NAME_AUDIT, row.getValueString("id")));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
@@ -165,6 +169,40 @@ public class TimelineService
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core words its audits "Record was Edited" or "Record was Inserted"; the timeline
|
||||
** says who did what instead: "Edited by Alice Morgan: Status, Total" or "Created by
|
||||
** Alice Morgan". The detail rows are unchanged. Any other message is kept as written.
|
||||
*******************************************************************************/
|
||||
public static String friendlySummary(String message, String actor, List<TimelineDetail> details, QTableMetaData table)
|
||||
{
|
||||
String verb = message == null ? null : message.startsWith("Record was Inserted") ? "Created" : message.startsWith("Record was Edited") ? "Edited"
|
||||
: message.startsWith("Record was Deleted") ? "Deleted" : null;
|
||||
if(verb == null)
|
||||
{
|
||||
return (message);
|
||||
}
|
||||
StringBuilder summary = new StringBuilder(verb);
|
||||
if(actor != null && !actor.isBlank())
|
||||
{
|
||||
summary.append(" by ").append(actor);
|
||||
}
|
||||
if(verb.equals("Edited"))
|
||||
{
|
||||
List<String> labels = details.stream().map(TimelineDetail::fieldName).filter(name -> name != null && !name.isBlank()).distinct()
|
||||
.map(name -> table != null && table.getFields().containsKey(name) && table.getFields().get(name).getLabel() != null ? table.getFields().get(name).getLabel() : name).toList();
|
||||
if(!labels.isEmpty())
|
||||
{
|
||||
summary.append(": ").append(String.join(", ", labels.subList(0, Math.min(MAX_FIELDS_IN_SUMMARY, labels.size()))));
|
||||
if(labels.size() > MAX_FIELDS_IN_SUMMARY)
|
||||
{
|
||||
summary.append(" and ").append(labels.size() - MAX_FIELDS_IN_SUMMARY).append(" more");
|
||||
}
|
||||
}
|
||||
}
|
||||
return (summary.toString());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit enforcement applies to every audit table, independent of host defaults.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -39,6 +39,10 @@ components:
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
type: "string"
|
||||
handledByDisplayName:
|
||||
description: "Display name of the person who handled the request."
|
||||
nullable: true
|
||||
type: "string"
|
||||
id:
|
||||
description: "id."
|
||||
nullable: true
|
||||
@@ -55,6 +59,10 @@ components:
|
||||
description: "recordId."
|
||||
nullable: true
|
||||
type: "string"
|
||||
requesterDisplayName:
|
||||
description: "Requester display name."
|
||||
nullable: true
|
||||
type: "string"
|
||||
requesterUserId:
|
||||
description: "requesterUserId."
|
||||
nullable: true
|
||||
@@ -81,6 +89,10 @@ components:
|
||||
type: "object"
|
||||
CollaborationAssignmentV1:
|
||||
properties:
|
||||
assigneeDisplayName:
|
||||
description: "Assignee display name."
|
||||
nullable: true
|
||||
type: "string"
|
||||
assigneeUserId:
|
||||
description: "assigneeUserId."
|
||||
nullable: true
|
||||
@@ -89,6 +101,10 @@ components:
|
||||
description: "id."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
ownerDisplayName:
|
||||
description: "Owner display name."
|
||||
nullable: true
|
||||
type: "string"
|
||||
ownerUserId:
|
||||
description: "ownerUserId."
|
||||
nullable: true
|
||||
@@ -159,6 +175,10 @@ components:
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
type: "string"
|
||||
createdByDisplayName:
|
||||
description: "Uploader display name."
|
||||
nullable: true
|
||||
type: "string"
|
||||
createdByUserId:
|
||||
description: "Uploader identity."
|
||||
nullable: true
|
||||
@@ -258,6 +278,10 @@ components:
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
type: "string"
|
||||
editedByDisplayName:
|
||||
description: "Editor display name."
|
||||
nullable: true
|
||||
type: "string"
|
||||
editedByUserId:
|
||||
description: "Editor identity."
|
||||
nullable: true
|
||||
@@ -494,6 +518,10 @@ components:
|
||||
- "SHARED"
|
||||
nullable: true
|
||||
type: "string"
|
||||
userDisplayName:
|
||||
description: "Follower display name."
|
||||
nullable: true
|
||||
type: "string"
|
||||
userId:
|
||||
description: "Follower identity."
|
||||
nullable: true
|
||||
@@ -699,6 +727,10 @@ components:
|
||||
type: "object"
|
||||
CollaborationShareV1:
|
||||
properties:
|
||||
fromDisplayName:
|
||||
description: "Sender display name."
|
||||
nullable: true
|
||||
type: "string"
|
||||
fromUserId:
|
||||
description: "fromUserId."
|
||||
nullable: true
|
||||
@@ -728,6 +760,10 @@ components:
|
||||
description: "tableName."
|
||||
nullable: true
|
||||
type: "string"
|
||||
toDisplayName:
|
||||
description: "Recipient display name."
|
||||
nullable: true
|
||||
type: "string"
|
||||
toUserId:
|
||||
description: "toUserId."
|
||||
nullable: true
|
||||
@@ -904,6 +940,10 @@ components:
|
||||
type: "object"
|
||||
CollaborationTaskV1:
|
||||
properties:
|
||||
assigneeDisplayName:
|
||||
description: "Assignee display name."
|
||||
nullable: true
|
||||
type: "string"
|
||||
assigneeUserId:
|
||||
description: "assigneeUserId."
|
||||
nullable: true
|
||||
@@ -913,10 +953,18 @@ components:
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
type: "string"
|
||||
completedByDisplayName:
|
||||
description: "Display name of the person who completed the task."
|
||||
nullable: true
|
||||
type: "string"
|
||||
completedByUserId:
|
||||
description: "completedByUserId."
|
||||
nullable: true
|
||||
type: "string"
|
||||
createdByDisplayName:
|
||||
description: "Creator display name."
|
||||
nullable: true
|
||||
type: "string"
|
||||
createdByUserId:
|
||||
description: "createdByUserId."
|
||||
nullable: true
|
||||
@@ -1046,11 +1094,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);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -32,6 +32,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -39,6 +40,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
|
||||
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.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -74,7 +77,7 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
MemoryStorage.objects.clear();
|
||||
MemoryStorage.lastGet = null;
|
||||
MemoryStorage.fail = false;
|
||||
config.withAttachmentStorage(new QCodeReference(MemoryStorage.class));
|
||||
config.withAttachmentStorage(new QCodeReference(MemoryStorage.class)).withUserSessionProvider(new QCodeReference(CarolSessions.class));
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(TestSessions.class)));
|
||||
int port;
|
||||
@@ -166,6 +169,67 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
assertError(400, "POST", "/records/document/1/tasks", "{\"title\":5}", "alice");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every payload that carries a user id also carries a display name, resolved in
|
||||
** one batch per response; ids with no directory entry fall back to themselves.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void payloadsCarryDisplayNames() throws Exception
|
||||
{
|
||||
assertThat(json(request("GET", "/people/carol", null, "carol"), 200).get("displayName").asText()).isEqualTo("Carol Diaz");
|
||||
|
||||
var task = ok("POST", "/records/document/1/tasks", "{\"title\":\"Review\",\"assigneeUserId\":\"carol\"}");
|
||||
assertThat(task.get("assigneeDisplayName").asText()).isEqualTo("Carol Diaz");
|
||||
assertThat(task.get("createdByDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
assertThat(task.get("completedByDisplayName").isNull()).isTrue();
|
||||
assertThat(ok("GET", "/records/document/1/tasks", null).at("/items/0/assigneeDisplayName").asText()).isEqualTo("Carol Diaz");
|
||||
assertThat(ok("GET", "/my-work", null).at("/tasks").size()).isZero();
|
||||
var done = ok("PATCH", "/tasks/" + task.get("id").asInt(), "{\"status\":\"DONE\"}");
|
||||
assertThat(done.get("completedByDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
assertThat(json(request("GET", "/tasks/mine?status=DONE", null, "carol"), 200).at("/items/0/createdByDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
|
||||
JsonNode upload = json(upload("alice", "report.txt", "hello", "/records/document/1/attachments"), 200);
|
||||
assertThat(upload.get("createdByDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
assertThat(ok("GET", "/records/document/1/attachments", null).at("/items/0/createdByDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
|
||||
JsonNode comment = ok("POST", "/records/document/1/comments", "{\"body\":\"First\"}");
|
||||
assertThat(comment.get("authorDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
ok("PATCH", "/comments/" + comment.get("id").asInt(), "{\"body\":\"Edited\"}");
|
||||
assertThat(ok("GET", "/comments/" + comment.get("id").asInt() + "/history", null).at("/items/0/editedByDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
assertThat(ok("GET", "/records/document/1/followers", null).findValuesAsText("userDisplayName")).contains("Alice Morgan");
|
||||
|
||||
JsonNode shared = ok("POST", "/records/document/1/shares", "{\"toUserIds\":[\"carol\"],\"message\":\"Look\"}");
|
||||
assertThat(shared.at("/items/0/fromDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
assertThat(shared.at("/items/0/toDisplayName").asText()).isEqualTo("Carol Diaz");
|
||||
|
||||
JsonNode request = ok("POST", "/access-requests", "{\"tableName\":\"document\",\"recordId\":\"999\",\"message\":\"Please\"}");
|
||||
assertThat(request.get("requesterDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
assertThat(request.get("handledByDisplayName").isNull()).isTrue();
|
||||
assertThat(ok("GET", "/access-requests/mine", null).at("/items/0/requesterDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
|
||||
JsonNode assignment = ok("PUT", "/records/document/1/assignment", "{\"ownerUserId\":\"alice\",\"assigneeUserId\":\"carol\"}");
|
||||
assertThat(assignment.get("ownerDisplayName").asText()).isEqualTo("Alice Morgan");
|
||||
assertThat(assignment.get("assigneeDisplayName").asText()).isEqualTo("Carol Diaz");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
@@ -256,6 +320,50 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
assertError(400, "PUT", "/records/document/1/tags", "{\"add\":[1.5]}", "alice");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The picker sends tag ids through the v1 table query as any JSON number or string.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tagFilterByIdThroughTableQuery() throws Exception
|
||||
{
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
int a = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Alpha\"}").get("id").asInt();
|
||||
int b = ok("POST", "/tags", "{\"table\":\"document\",\"label\":\"Beta\"}").get("id").asInt();
|
||||
ok("PUT", "/records/document/1/tags", "{\"add\":[" + a + "," + b + "]}");
|
||||
ok("PUT", "/records/document/2/tags", "{\"add\":[" + a + "]}");
|
||||
for(String ida : List.of(a + "", "\"" + a + "\"", a + ".0"))
|
||||
{
|
||||
assertThat(tagQuery("IN", "[" + ida + "]")).containsExactlyInAnyOrder(1, 2);
|
||||
assertThat(tagQuery("EQUALS", "[" + ida + "]")).containsExactlyInAnyOrder(1, 2);
|
||||
assertThat(tagQuery("NOT_IN", "[" + ida + "]")).isEmpty();
|
||||
}
|
||||
for(String idb : List.of(b + "", "\"" + b + "\"", b + ".0"))
|
||||
{
|
||||
assertThat(tagQuery("CONTAINS_ALL", "[" + a + "," + idb + "]")).containsExactly(1);
|
||||
assertThat(tagQuery("NOT_IN", "[" + idb + "]")).containsExactly(2);
|
||||
}
|
||||
assertThat(tagQuery("IN", "[\"alpha\"]")).containsExactlyInAnyOrder(1, 2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the v1 table query filtered on the virtual tags field; return matching ids.
|
||||
*******************************************************************************/
|
||||
private List<Integer> tagQuery(String operator, String values) throws Exception
|
||||
{
|
||||
String serverUrl = baseUrl.substring(0, baseUrl.indexOf("/qbit/"));
|
||||
String body = "{\"filter\":{\"criteria\":[{\"fieldName\":\"collaborationTags\",\"operator\":\"" + operator + "\",\"values\":" + values + "}],\"orderBys\":[{\"fieldName\":\"id\"}]}}";
|
||||
HttpResponse<String> response = client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/table/document/query")).timeout(Duration.ofSeconds(20))
|
||||
.header("Cookie", "sessionUUID=alice" + sessionSuffix).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode result = json(response, 200);
|
||||
List<Integer> ids = new java.util.ArrayList<>();
|
||||
for(JsonNode record : result.get("records"))
|
||||
{
|
||||
ids.add(record.at("/values/id").asInt());
|
||||
}
|
||||
return (ids);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Comments, revisions, mentions, follow settings and the merged timeline.
|
||||
*******************************************************************************/
|
||||
@@ -282,12 +390,17 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isTrue();
|
||||
ok("POST", "/comments/" + id + "/reopen", null);
|
||||
assertThat(ok("GET", "/records/document/1/comments", null).at("/threads/0/resolved").asBoolean()).isFalse();
|
||||
JsonNode people = ok("GET", "/people?q=alice", null);
|
||||
assertThat(people.at("/items/0/userId").asText()).isEqualTo("alice");
|
||||
assertThat(ok("GET", "/people/alice", null).get("displayName").asText()).isEqualTo("alice");
|
||||
JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=alice", null).at("/items/0");
|
||||
assertThat(candidate.at("/person/userId").asText()).isEqualTo("alice");
|
||||
assertThat(candidate.get("canRead").asText()).isEqualTo("unknown");
|
||||
assertThat(json(request("GET", "/people/carol", null, "carol"), 200).get("displayName").asText()).isEqualTo("Carol Diaz");
|
||||
JsonNode people = ok("GET", "/people?q=carol", null);
|
||||
assertThat(people.at("/items/0/userId").asText()).isEqualTo("carol");
|
||||
assertThat(ok("GET", "/people?q=alice", null).get("items")).as("the caller is not offered to themself").isEmpty();
|
||||
assertThat(ok("GET", "/people", null).findValuesAsText("userId")).doesNotContain("alice").contains("carol");
|
||||
assertThat(ok("GET", "/people/alice", null).get("displayName").asText()).isEqualTo("Alice Morgan");
|
||||
assertThat(ok("GET", "/mentions/candidates?table=document&pk=1&q=alice", null).get("items")).isEmpty();
|
||||
assertThat(ok("GET", "/mentions/candidates?table=document&pk=1", null).findValuesAsText("userId")).doesNotContain("alice");
|
||||
JsonNode candidate = ok("GET", "/mentions/candidates?table=document&pk=1&q=carol", null).at("/items/0");
|
||||
assertThat(candidate.at("/person/userId").asText()).isEqualTo("carol");
|
||||
assertThat(candidate.get("canRead").asText()).isEqualTo("true");
|
||||
assertThat(ok("GET", "/records/document/1/follow", null).at("/follow/reason").asText()).isEqualTo("COMMENTED");
|
||||
JsonNode state = ok("PUT", "/records/document/1/follow", "{\"notifyOnChange\":false,\"notifyOnComment\":true}");
|
||||
assertThat(state.at("/follow/notifyOnChange").asBoolean()).isFalse();
|
||||
@@ -421,15 +534,33 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
public void customizeSession(QInstance qInstance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
String caller = session.getUuid().split("-")[0];
|
||||
session.withUser(new QUser().withIdReference(caller).withFullName(caller));
|
||||
session.withUser(new QUser().withIdReference(caller).withFullName(switch(caller)
|
||||
{
|
||||
case "alice" -> "Alice Morgan";
|
||||
case "carol" -> "Carol Diaz";
|
||||
default -> caller;
|
||||
}));
|
||||
session.withPermissions("document.read").withSecurityKeyValue("tenant", caller.equals("outsider") ? "b" : "a");
|
||||
if(caller.equals("alice"))
|
||||
if(caller.equals("alice") || caller.equals("carol"))
|
||||
{
|
||||
session.withPermissions("document.write");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Carol is the one other person who can open the shared documents.
|
||||
*******************************************************************************/
|
||||
public static class CarolSessions implements UserSessionProviderInterface
|
||||
{
|
||||
@Override
|
||||
public Optional<QSession> sessionForUser(String userId)
|
||||
{
|
||||
return (Optional.ofNullable("carol".equals(userId) ? new QSession().withUser(new QUser().withIdReference("carol")).withPermissions("document.read", "document.write")
|
||||
.withSecurityKeyValue("tenant", "a") : null));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Public no-arg adapter loaded through host configuration, with in-memory bytes.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -23,7 +23,6 @@ package com.kingsrook.qbits.collaboration;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
@@ -40,7 +39,8 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
@@ -114,9 +114,18 @@ 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);
|
||||
assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET);
|
||||
assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull();
|
||||
.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()).containsExactlyElementsOf(CollaborationHome.HOME_WIDGETS);
|
||||
for(String widget : CollaborationHome.HOME_WIDGETS)
|
||||
{
|
||||
assertThat(instance.getWidget(widget)).as(widget).isNotNull();
|
||||
assertThat(instance.getWidget(widget).getGridColumns()).isEqualTo(12);
|
||||
assertThat(instance.getWidget(widget).getIsCard()).isFalse();
|
||||
}
|
||||
assertThat(instance.getWidget(CollaborationHome.KPI_WIDGET).getType()).isEqualTo("multiStatistics");
|
||||
assertThat(instance.getWidget(CollaborationHome.TAGS_WIDGET).getType()).isEqualTo("table");
|
||||
assertThat(instance.getWidget(CollaborationHome.FIELDS_WIDGET).getType()).isEqualTo("table");
|
||||
assertThat(instance.getWidget(CollaborationHome.PRESENCE_WIDGET).getType()).isEqualTo("table");
|
||||
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
|
||||
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getUniqueKeys().getFirst().getFieldNames()).containsExactly("scopeType", "scopeName", "feature");
|
||||
assertThat(instance.getTable(CollaborationTables.USER).getUniqueKeys().getFirst().getFieldNames()).containsExactly("userId");
|
||||
@@ -168,6 +177,41 @@ class CollaborationTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a parent app, Collaboration is a top-level node of the frontend app tree.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appTreeTopLevelWithoutParent() throws Exception
|
||||
{
|
||||
new QInstanceValidator().validate(instance);
|
||||
var output = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction().execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput());
|
||||
assertThat(output.getApps()).containsKey("collaboration");
|
||||
assertThat(output.getAppTree()).extracting(node -> node.getName()).contains("collaboration");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With a parent app, Collaboration is nested under it and not duplicated at the top.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appTreeNestedUnderParent() throws Exception
|
||||
{
|
||||
instance = new QInstance();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin").withIcon(new QIcon("settings")));
|
||||
new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory").withParentAppName("admin")).produce(instance);
|
||||
QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("admin")).withPermissions(CollaborationPermissions.ADMIN));
|
||||
new QInstanceValidator().validate(instance);
|
||||
var output = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction().execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput());
|
||||
assertThat(output.getAppTree()).extracting(node -> node.getName()).containsExactly("admin");
|
||||
assertThat(output.getAppTree().getFirst().getChildren()).extracting(node -> node.getName()).contains("collaboration");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Headless hosts keep the tables without app or widgets.
|
||||
*******************************************************************************/
|
||||
@@ -177,9 +221,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -190,29 +234,59 @@ class CollaborationTest
|
||||
@Test
|
||||
void emptyHome() throws Exception
|
||||
{
|
||||
String html = CollaborationHome.render(Instant.now());
|
||||
assertThat(html).contains("No feature settings yet: features follow the code defaults", "People known: 0", "Active in the last 7 days: 0", "No people yet");
|
||||
RawHTML widget = (RawHTML) new CollaborationHome.HomeRenderer().render(new RenderWidgetInput()).getWidgetData();
|
||||
assertThat(widget.getHtml()).contains("People known: 0");
|
||||
assertThat(HomeWidgetData.stats(Instant.now())).containsExactly(
|
||||
java.util.Map.entry("Comments this week", 0), java.util.Map.entry("Open threads", 0), java.util.Map.entry("Open tasks", 0),
|
||||
java.util.Map.entry("Overdue tasks", 0), java.util.Map.entry("Open access requests", 0), java.util.Map.entry("Attachments", 0),
|
||||
java.util.Map.entry("Active custom fields", 0));
|
||||
assertThat(CollaborationHome.tags().getRows()).isEmpty();
|
||||
assertThat(CollaborationHome.tags().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_TAGS);
|
||||
assertThat(CollaborationHome.fields().getRows()).isEmpty();
|
||||
assertThat(CollaborationHome.fields().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_FIELDS);
|
||||
assertThat(CollaborationHome.presence().getRows()).isEmpty();
|
||||
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo("Nobody else is here right now.");
|
||||
assertThat(((TableData) new CollaborationHome.PresenceRenderer().render(new RenderWidgetInput()).getWidgetData()).getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NOBODY_HERE);
|
||||
assertThat(new CollaborationHome.KpiRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(MultiStatisticsData.class);
|
||||
assertThat(new CollaborationHome.TagsRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(TableData.class);
|
||||
assertThat(new CollaborationHome.FieldsRenderer().render(new RenderWidgetInput()).getWidgetData()).isInstanceOf(TableData.class);
|
||||
assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Work")).isEqualTo("Nothing waiting");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts distinguish disabled settings and the inclusive seven-day window.
|
||||
** Display names resolve in one query; unknown, blank and null ids stay readable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void realCounts() throws Exception
|
||||
void displayNamesResolveInBatches() throws Exception
|
||||
{
|
||||
Instant now = Instant.parse("2026-10-13T12:00:00Z");
|
||||
seed(CollaborationTables.FEATURE_SETTING,
|
||||
setting("INSTANCE", "instance", "COMMENTS", true), setting("APP", "sales", "TAGS", true),
|
||||
setting("TABLE", "orders", "TASKS", true), setting("TABLE", "orders", "COMMENTS", true),
|
||||
setting("TABLE", "orders", "PRESENCE", false));
|
||||
seed(CollaborationTables.USER, person("one", now), person("boundary", now.minus(7, ChronoUnit.DAYS)),
|
||||
person("old", now.minus(7, ChronoUnit.DAYS).minusSeconds(1)), person("never", null), person("future", now.plusSeconds(1)));
|
||||
String html = CollaborationHome.render(now);
|
||||
assertThat(html).contains("INSTANCE: 1", "APP: 1", "TABLE: 2", "People known: 5", "Active in the last 7 days: 2").doesNotContain("No feature settings yet", "No people yet");
|
||||
seed(CollaborationTables.USER, new QRecord().withValue("userId", "ann").withValue("displayName", "Ann Lee").withValue("active", true),
|
||||
new QRecord().withValue("userId", "nameless").withValue("displayName", " ").withValue("active", true));
|
||||
var names = new com.kingsrook.qbits.collaboration.people.PeopleService().displayNames(java.util.Arrays.asList("ann", "nameless", "ghost", null, "", "ann"));
|
||||
assertThat(names).containsExactly(java.util.Map.entry("ann", "Ann Lee"), java.util.Map.entry("nameless", "nameless"), java.util.Map.entry("ghost", "ghost"));
|
||||
assertThat(new com.kingsrook.qbits.collaboration.people.PeopleService().displayNames(List.of())).isEmpty();
|
||||
assertThat(instance.getTable(CollaborationTables.TASK).getField("assigneeUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
|
||||
assertThat(instance.getTable(CollaborationTables.COMMENT).getField("authorUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
|
||||
assertThat(instance.getTable(CollaborationTables.ATTACHMENT).getField("createdByUserId").getPossibleValueSourceName()).isEqualTo(CollaborationTables.USER);
|
||||
assertThat(instance.getTable(CollaborationTables.USER).getField("userId").getPossibleValueSourceName()).isNull();
|
||||
assertThat(instance.getPossibleValueSource(CollaborationTables.USER).getOverrideIdField()).isEqualTo("userId");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tags and custom fields list real rows with table labels, All tables for global ones.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tablesListRealRows() throws Exception
|
||||
{
|
||||
seed(CollaborationTables.TAG,
|
||||
new QRecord().withValue("name", "global").withValue("label", "Global").withValue("approved", true).withValue("createdByUserId", "admin").withValue("usageCount", 3),
|
||||
new QRecord().withValue("name", "popular").withValue("label", "Popular").withValue("approved", true).withValue("createdByUserId", "admin").withValue("usageCount", 5));
|
||||
var tags = CollaborationHome.tags();
|
||||
assertThat(tags.getColumns()).extracting(TableData.Column::getHeader).containsExactly("Label", "Table", "Records");
|
||||
assertThat(tags.getRows()).hasSize(2);
|
||||
assertThat(tags.getRows().get(0)).containsEntry("label", "Popular").containsEntry("table", "All tables").containsEntry("records", 5);
|
||||
assertThat(tags.getRows().get(1)).containsEntry("label", "Global").containsEntry("table", "All tables").containsEntry("records", 3);
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +299,7 @@ class CollaborationTest
|
||||
{
|
||||
QContext.getQSession().setPermissions(new java.util.HashSet<>());
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.USER))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> CollaborationHome.render(Instant.now())).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> CollaborationHome.kpis(Instant.now())).isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.getQSession().withPermissions(CollaborationPermissions.VIEW);
|
||||
new QueryAction().execute(new QueryInput(CollaborationTables.FEATURE_SETTING));
|
||||
new QueryAction().execute(new QueryInput(CollaborationTables.USER));
|
||||
@@ -252,14 +326,16 @@ class CollaborationTest
|
||||
void homeUsesCurrentSession() throws Exception
|
||||
{
|
||||
Instant now = Instant.now();
|
||||
seed(CollaborationTables.USER, person("visible", now), person("hidden", now));
|
||||
seed(CollaborationTables.TAG, new QRecord().withValue("name", "visible").withValue("label", "Visible").withValue("approved", true).withValue("createdByUserId", "admin"),
|
||||
new QRecord().withValue("name", "hidden").withValue("label", "Hidden").withValue("approved", true).withValue("createdByUserId", "admin"));
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("person").withAllAccessKeyName("allPeople"));
|
||||
instance.getTable(CollaborationTables.USER).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("person").withFieldName("userId"));
|
||||
instance.getTable(CollaborationTables.TAG).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("person").withFieldName("name"));
|
||||
QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("viewer")).withPermissions(CollaborationPermissions.VIEW).withSecurityKeyValue("person", "visible"));
|
||||
assertThat(CollaborationHome.render(now)).contains("People known: 1", "Active in the last 7 days: 1");
|
||||
assertThat(CollaborationHome.tags().getRows()).singleElement().satisfies(row -> assertThat(row).containsEntry("label", "Visible"));
|
||||
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY);
|
||||
instance.setEnforcePermissionsInCoreActions(false);
|
||||
QContext.getQSession().setPermissions(new java.util.HashSet<>());
|
||||
assertThatThrownBy(() -> new CollaborationHome.HomeRenderer().render(new RenderWidgetInput())).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new CollaborationHome.KpiRenderer().render(new RenderWidgetInput())).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -347,11 +347,15 @@ public class ConversationServicesTest extends PersonalServiceFixture
|
||||
new PeopleService(Clock.fixed(at, ZoneOffset.UTC)).touch();
|
||||
QContext.getQSession().getUser().setFullName("Changed Alice");
|
||||
new PeopleService(Clock.fixed(at.plusSeconds(599), ZoneOffset.UTC)).touch();
|
||||
assertThat(new PeopleService().search("Alice", 5)).singleElement().satisfies(p -> assertThat(p.displayName()).isEqualTo("Alice Example"));
|
||||
new PeopleService(Clock.fixed(at.plusSeconds(600), ZoneOffset.UTC)).touch();
|
||||
assertThat(new PeopleService().search("changed", 5)).hasSize(1);
|
||||
assertThat(new PeopleService().search("Alice", 5)).as("the caller never finds themself").isEmpty();
|
||||
login("bob");
|
||||
new PeopleService().touch();
|
||||
assertThat(new PeopleService().search("Alice", 5)).singleElement().satisfies(p -> assertThat(p.displayName()).isEqualTo("Alice Example"));
|
||||
login("alice");
|
||||
QContext.getQSession().getUser().setFullName("Changed Alice");
|
||||
new PeopleService(Clock.fixed(at.plusSeconds(600), ZoneOffset.UTC)).touch();
|
||||
login("bob");
|
||||
assertThat(new PeopleService().search("changed", 5)).hasSize(1);
|
||||
follows.follow("document", 1, null, null);
|
||||
login("eve");
|
||||
new PeopleService().touch();
|
||||
@@ -507,8 +511,7 @@ public class ConversationServicesTest extends PersonalServiceFixture
|
||||
assertThat(sql).contains("ON collaboration_comment (table_name, record_id, create_date)", "ON collaboration_follow_digest (due_at)");
|
||||
comments.post("document", 1, "home", null, null);
|
||||
admin();
|
||||
String html = com.kingsrook.qbits.collaboration.app.CollaborationHome.render(Instant.now().plusSeconds(1));
|
||||
assertThat(html).contains("Comments this week</h3><p>1", "Open threads</h3><p>1", "Followers</h3><p>1");
|
||||
assertThat(HomeWidgetData.stats(Instant.now().plusSeconds(1))).containsEntry("Comments this week", 1).containsEntry("Open threads", 1);
|
||||
assertThatThrownBy(() -> config.withFollowActionTables(List.of("missing")).validate(instance)).hasMessageContaining("host table");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,319 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home lists definitions with table label, status and index state, and counts active ones.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeListsDefinitionsAndCountsActiveOnes() throws Exception
|
||||
{
|
||||
admin();
|
||||
QRecord definition = draft("region", "STRING");
|
||||
var drafts = CollaborationHome.fields();
|
||||
assertThat(drafts.getColumns()).extracting(TableData.Column::getHeader).containsExactly("Name", "Table", "Status", "Index state");
|
||||
assertThat(drafts.getRows()).singleElement().satisfies(row -> assertThat(row).containsEntry("name", "region").containsEntry("table", "Documents")
|
||||
.containsEntry("status", "Draft").containsEntry("indexState", "None"));
|
||||
assertThat(HomeWidgetData.stats(java.time.Instant.now())).containsEntry("Active custom fields", 0);
|
||||
FieldDefinitions.apply(definition.getValue("id"), "Publish");
|
||||
assertThat(CollaborationHome.fields().getRows().getFirst()).containsEntry("status", "Active");
|
||||
assertThat(HomeWidgetData.stats(java.time.Instant.now())).containsEntry("Active custom fields", 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test view of the home KPI widget: statistic label to value, and group subheaders.
|
||||
*******************************************************************************/
|
||||
public final class HomeWidgetData
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private HomeWidgetData()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every statistic on the KPI widget, by label.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Integer> stats(Instant now) throws QException
|
||||
{
|
||||
Map<String, Integer> result = new LinkedHashMap<>();
|
||||
CollaborationHome.kpis(now).getStatisticsGroupData().forEach(group -> group.getStatisticList().forEach(stat -> result.put(stat.getLabel(), stat.getValue())));
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The subheader of the named KPI group.
|
||||
*******************************************************************************/
|
||||
public static String subheader(MultiStatisticsData data, String header)
|
||||
{
|
||||
return (data.getStatisticsGroupData().stream().filter(group -> header.equals(group.getHeader())).findFirst().orElseThrow().getSubheader());
|
||||
}
|
||||
}
|
||||
@@ -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,193 @@
|
||||
/*
|
||||
* 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);
|
||||
admin();
|
||||
assertThat(CollaborationHome.presence().getRows()).extracting(row -> row.get("table")).containsExactly("Documents");
|
||||
assertThat(CollaborationHome.presence().getRows().getFirst().get("viewers")).isEqualTo(1);
|
||||
login("alice");
|
||||
assertThat(CollaborationHome.presence().getRows()).isEmpty();
|
||||
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY);
|
||||
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.fields().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_FIELDS);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.assignment.AssignmentService;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentsService;
|
||||
import com.kingsrook.qbits.collaboration.follow.FollowService;
|
||||
@@ -510,8 +509,7 @@ public class SharingWorkTest extends PersonalServiceFixture
|
||||
sharing.share("document", 1, List.of("bob"), null);
|
||||
access.request("document", 999, null, null);
|
||||
admin();
|
||||
String html = CollaborationHome.render(Instant.now().plusSeconds(5));
|
||||
assertThat(html).contains("Open access requests</h3><p>1", "Open tasks</h3><p>1", "Overdue tasks</h3><p>1", "Shares this week</h3><p>1");
|
||||
assertThat(HomeWidgetData.stats(Instant.now().plusSeconds(5))).containsEntry("Open access requests", 1).containsEntry("Open tasks", 1).containsEntry("Overdue tasks", 1);
|
||||
assertThat(SendTaskRemindersProcess.metadata(config).getIsHidden()).isTrue();
|
||||
assertThat(SendTaskRemindersProcess.metadata(config).getSchedule()).isNull();
|
||||
assertThat(SendTaskRemindersProcess.metadata(config.withSchedulerName("clock")).getSchedule().getRepeatSeconds()).isEqualTo(60);
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -311,9 +310,11 @@ public class TagsServiceTest extends PersonalServiceFixture
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b"));
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_TAG)).getRecords()).isEmpty();
|
||||
login("manager", "collaboration.admin", TagsService.MANAGE);
|
||||
String html = CollaborationHome.render(Instant.now());
|
||||
assertThat(html).contains("Vocabulary size: 2", "Pending suggestions: 1", "<Urgent>: 1");
|
||||
assertThat(html).doesNotContain("<Urgent>");
|
||||
var home = CollaborationHome.tags();
|
||||
assertThat(home.getRows()).hasSize(2);
|
||||
assertThat(home.getRows().getFirst()).containsEntry("label", "<Urgent>").containsEntry("table", "Documents").containsEntry("records", 1);
|
||||
assertThat(home.getRows().getLast()).containsEntry("records", 0);
|
||||
assertThat(home.getNoRowsFoundHTML()).isEqualTo(CollaborationHome.NO_TAGS);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -445,6 +446,36 @@ public class TagsServiceTest extends PersonalServiceFixture
|
||||
{
|
||||
return ((List<ProcessSummaryLine>) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY));
|
||||
}
|
||||
/*******************************************************************************
|
||||
** JSON clients send ids as Integer, Long, BigDecimal or String; all select by id.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void translatorAcceptsEveryIdRepresentation() throws Exception
|
||||
{
|
||||
TagEntry a = tags.create("document", "A", null);
|
||||
TagEntry b = tags.create("document", "B", null);
|
||||
TagEntry numeric = tags.create("document", "77", null);
|
||||
tags.apply("document", 1, List.of(a.id(), b.id()), List.of());
|
||||
tags.apply("document", 2, List.of(a.id()), List.of());
|
||||
for(java.io.Serializable idA : List.<java.io.Serializable>of(a.id(), Long.valueOf(a.id()), new java.math.BigDecimal(a.id()), String.valueOf(a.id())))
|
||||
{
|
||||
assertThat(recordIds("document", "IN", List.of(idA))).containsExactly("1", "2");
|
||||
assertThat(recordIds("document", "EQUALS", List.of(idA))).containsExactly("1", "2");
|
||||
assertThat(recordIds("document", "NOT_IN", List.of(idA))).isEmpty();
|
||||
}
|
||||
for(java.io.Serializable idB : List.<java.io.Serializable>of(b.id(), Long.valueOf(b.id()), new java.math.BigDecimal(b.id()), String.valueOf(b.id())))
|
||||
{
|
||||
assertThat(recordIds("document", "IN", List.of(idB))).containsExactly("1");
|
||||
assertThat(recordIds("document", "NOT_IN", List.of(idB))).containsExactly("2");
|
||||
assertThat(recordIds("document", "CONTAINS_ALL", List.of(String.valueOf(a.id()), idB))).containsExactly("1");
|
||||
}
|
||||
assertThat(recordIds("document", "CONTAINS_ALL", List.of(new java.math.BigDecimal(a.id()), Long.valueOf(b.id())))).containsExactly("1");
|
||||
assertThat(recordIds("document", "IN", List.of("9999"))).isEmpty();
|
||||
tags.apply("document", 2, List.of(numeric.id()), List.of());
|
||||
assertThat(recordIds("document", "IN", List.of(String.valueOf(numeric.id())))).containsExactly("2");
|
||||
assertThat(recordIds("document", "IN", List.of("77"))).containsExactly("2");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute expanded criteria through the real parent query action.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineDetail;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineItem;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelinePage;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineService;
|
||||
@@ -116,6 +117,48 @@ public class TimelineServiceTest extends PersonalServiceFixture
|
||||
assertThat(timeline.timeline("document", 2, null, 50, null).items()).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core wording becomes who-did-what; detail rows and unknown messages are untouched.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void auditSummariesSayWhoDidWhat() throws Exception
|
||||
{
|
||||
seedAudit(4, "Record was Edited", 100);
|
||||
seedAudit(5, "Record was Inserted", 101);
|
||||
seedAudit(6, "Record was Edited", 102);
|
||||
seedAudit(7, "Record was Deleted", 103);
|
||||
seed("auditDetail", new QRecord().withValue("id", 10L).withValue("auditId", 4L).withValue("fieldName", "title").withValue("oldValue", "Old").withValue("newValue", "New"));
|
||||
seed("auditDetail", new QRecord().withValue("id", 11L).withValue("auditId", 4L).withValue("fieldName", "tenant").withValue("oldValue", "a").withValue("newValue", "b"));
|
||||
seed("auditDetail", new QRecord().withValue("id", 12L).withValue("auditId", 4L).withValue("fieldName", "title").withValue("oldValue", "New").withValue("newValue", "Newer"));
|
||||
seed("auditDetail", new QRecord().withValue("id", 13L).withValue("auditId", 5L).withValue("fieldName", "title").withValue("newValue", "Created title"));
|
||||
var items = timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items();
|
||||
java.util.function.Function<Long, TimelineItem> byId = id -> items.stream().filter(item -> item.id().equals(id)).findFirst().orElseThrow();
|
||||
assertThat(byId.apply(4L).summary()).isEqualTo("Edited by Audit Actor: Title, Tenant");
|
||||
assertThat(byId.apply(4L).details()).hasSize(3);
|
||||
assertThat(byId.apply(5L).summary()).isEqualTo("Created by Audit Actor");
|
||||
assertThat(byId.apply(5L).details()).hasSize(1);
|
||||
assertThat(byId.apply(6L).summary()).isEqualTo("Edited by Audit Actor");
|
||||
assertThat(byId.apply(7L).summary()).isEqualTo("Deleted by Audit Actor");
|
||||
assertThat(byId.apply(1L).summary()).as("unrecognized messages are kept").isEqualTo("Record changed");
|
||||
assertThat(items).extracting(TimelineItem::summary).noneMatch(summary -> summary.startsWith("Record was"));
|
||||
|
||||
assertThat(TimelineService.friendlySummary("Record was Inserted", null, List.of(), null)).isEqualTo("Created");
|
||||
assertThat(TimelineService.friendlySummary("Record was Inserted via import", " ", List.of(), null)).isEqualTo("Created");
|
||||
assertThat(TimelineService.friendlySummary(null, "A", List.of(), null)).isNull();
|
||||
List<TimelineDetail> many = java.util.stream.IntStream.rangeClosed(1, 6).mapToObj(n -> new TimelineDetail("f" + n, "a", "b", null)).toList();
|
||||
assertThat(TimelineService.friendlySummary("Record was Edited", "A", many, null)).isEqualTo("Edited by A: f1, f2, f3, f4 and 2 more");
|
||||
assertThat(TimelineService.friendlySummary("Record was Edited", "A", List.of(new TimelineDetail(null, null, null, "note"), new TimelineDetail(" ", null, null, null)), null)).isEqualTo("Edited by A");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One audit header for the first document, as a different time.
|
||||
*******************************************************************************/
|
||||
private void seedAudit(long id, String message, long secondsAfter) throws Exception
|
||||
{
|
||||
seed("audit", new QRecord().withValue("id", id).withValue("auditTableId", 1).withValue("auditUserId", 1)
|
||||
.withValue("recordId", "1").withValue("timestamp", at.plusSeconds(secondsAfter)).withValue("message", message));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Audit read permission and hidden fields apply.
|
||||
*******************************************************************************/
|
||||
|
||||
+10
-2
@@ -34,6 +34,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSchema;
|
||||
import com.kingsrook.qbits.collaboration.HomeWidgetData;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
@@ -430,10 +431,17 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
void homeAndSchema() throws Exception
|
||||
{
|
||||
admin();
|
||||
assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 0", "No attachments yet.", "Total size (bytes): 0");
|
||||
assertThat(HomeWidgetData.stats(Instant.now())).containsEntry("Attachments", 0);
|
||||
assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Attachments")).isEqualTo("No attachments yet");
|
||||
upload("a.txt", "12345");
|
||||
upload("b.txt", "123");
|
||||
assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 2", "Total size (bytes): 8", "Approximate totals").doesNotContain("No attachments yet.");
|
||||
assertThat(HomeWidgetData.stats(Instant.now())).containsEntry("Attachments", 2);
|
||||
assertThat(HomeWidgetData.subheader(CollaborationHome.kpis(Instant.now()), "Attachments")).isEqualTo("8 B in total");
|
||||
assertThat(CollaborationHome.humanSize(0)).isEqualTo("0 B");
|
||||
assertThat(CollaborationHome.humanSize(1023)).isEqualTo("1023 B");
|
||||
assertThat(CollaborationHome.humanSize(4813)).isEqualTo("4.7 KB");
|
||||
assertThat(CollaborationHome.humanSize(5L * 1024 * 1024)).isEqualTo("5.0 MB");
|
||||
assertThat(CollaborationHome.humanSize(3L * 1024 * 1024 * 1024)).isEqualTo("3.0 GB");
|
||||
String ddl = CollaborationSchema.postgresScript(config);
|
||||
assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS collaboration_attachment", "size_bytes BIGINT", "parent_deleted BOOLEAN DEFAULT FALSE NOT NULL",
|
||||
"ON collaboration_attachment (table_name, record_id)").doesNotContain("preview_url", "download_url");
|
||||
|
||||
+733
@@ -0,0 +1,733 @@
|
||||
/*
|
||||
* 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.HashMap;
|
||||
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");
|
||||
static final String USER_COOKIE = "collab_dev_user";
|
||||
private static final ThreadLocal<String> REQUEST_USER = new ThreadLocal<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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(port);
|
||||
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";
|
||||
userSelection(config);
|
||||
MemoryStorage.routes(config);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pick the demo user per browser, not per JVM. A request carrying ?user=bob (for
|
||||
** example the page load of /?createMockSession=true&user=bob) is remembered in a
|
||||
** cookie, and every request exposes its choice to the session customizer, which
|
||||
** QQQ calls without the request. Without either, the collab.dev.user default holds.
|
||||
*******************************************************************************/
|
||||
static void userSelection(JavalinConfig config)
|
||||
{
|
||||
config.routes.before(context ->
|
||||
{
|
||||
String requested = context.queryParam("user");
|
||||
if(requested != null && USERS.contains(requested))
|
||||
{
|
||||
context.cookie(USER_COOKIE, requested);
|
||||
REQUEST_USER.set(requested);
|
||||
return;
|
||||
}
|
||||
String remembered = context.cookie(USER_COOKIE);
|
||||
REQUEST_USER.set(remembered != null && USERS.contains(remembered) ? remembered : null);
|
||||
});
|
||||
config.routes.after(context -> REQUEST_USER.remove());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The user for the request being handled on this thread, else the JVM default.
|
||||
*******************************************************************************/
|
||||
static String selectedUser()
|
||||
{
|
||||
String requested = REQUEST_USER.get();
|
||||
return (requested != null ? requested : System.getProperty("collab.dev.user", "alice"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normal host metadata, plus the same collaboration installation as a real app.
|
||||
*******************************************************************************/
|
||||
static QInstance buildInstance() throws Exception
|
||||
{
|
||||
return (buildInstance(Integer.parseInt(System.getProperty("collab.dev.port", "8098"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The port is part of the configuration: shared links are absolute URLs to this server.
|
||||
*******************************************************************************/
|
||||
static QInstance buildInstance(int port) 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).withFrontendBaseUrl("http://localhost:" + port)
|
||||
.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")
|
||||
.withValueForFrontend("user", new HashMap<>(Map.of("name", name, "email", user + "@example.com")));
|
||||
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.hasAccess", "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(selectedUser());
|
||||
target.setUser(selected.getUser());
|
||||
target.setPermissions(selected.getPermissions());
|
||||
target.setValuesForFrontend(selected.getValuesForFrontend());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
* 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");
|
||||
int port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
instance = CollaborationDevServer.buildInstance(port);
|
||||
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 TimelineService().timeline("person", 1, null, 100, null).items()).extracting(item -> item.summary())
|
||||
.anyMatch(summary -> summary.startsWith("Edited by ") && summary.contains("Title")).noneMatch(summary -> summary.startsWith("Record was"));
|
||||
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());
|
||||
assertThat(mock.getUser().getFullName()).isEqualTo(CollaborationDevServer.session(user).getUser().getFullName()).isNotEqualTo("Anonymous").isNotEqualTo("John Smith");
|
||||
assertThat(mock.getValuesForFrontend()).containsEntry("user", CollaborationDevServer.session(user).getValuesForFrontend().get("user"));
|
||||
}
|
||||
assertThatThrownBy(() -> CollaborationDevServer.session("unknown")).isInstanceOf(IllegalArgumentException.class);
|
||||
System.setProperty("collab.dev.user", "alice");
|
||||
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"));
|
||||
assertThat(new SharingService().link("person", 1).url()).isEqualTo("http://localhost:" + port + "/app/person/1");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Two browsers see two users: ?user= is remembered in a cookie, the session carries
|
||||
** the person's real name, and without a choice the JVM default still applies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void eachBrowserSessionChoosesItsOwnUser() throws Exception
|
||||
{
|
||||
String oldUser = System.getProperty("collab.dev.user");
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
CollaborationDevServer.MemoryStorage.clear();
|
||||
QInstance instance = null;
|
||||
QApplicationJavalinServer server = null;
|
||||
try(HttpClient client = HttpClient.newHttpClient())
|
||||
{
|
||||
System.setProperty("collab.dev.user", "admin");
|
||||
int port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
instance = CollaborationDevServer.buildInstance(port);
|
||||
CollaborationDevServer.seed(instance);
|
||||
server = CollaborationDevServer.server(instance, port, false);
|
||||
server.start();
|
||||
String base = "http://localhost:" + port;
|
||||
|
||||
var chosen = get(client, base + "/qqq/v1/version?createMockSession=true&user=bob");
|
||||
assertThat(chosen.headers().allValues("Set-Cookie")).anySatisfy(cookie -> assertThat(cookie).startsWith("collab_dev_user=bob"));
|
||||
assertThat(get(client, base + "/qqq/v1/version?user=nobody").headers().allValues("Set-Cookie")).noneMatch(cookie -> cookie.startsWith("collab_dev_user"));
|
||||
|
||||
var manage = client.send(HttpRequest.newBuilder(URI.create(base + "/qqq/v1/manageSession")).timeout(Duration.ofSeconds(20))
|
||||
.header("Cookie", "collab_dev_user=bob").header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"accessToken\":\"anonymous\"}")).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(new ObjectMapper().readTree(manage.body()).at("/values/user/name").asText()).as(manage.body()).isEqualTo("Bob Chen");
|
||||
assertThat(commenter(client, base, "bob")).containsExactly("bob", "Bob Chen");
|
||||
assertThat(commenter(client, base, "alice")).containsExactly("alice", "Alice Morgan");
|
||||
assertThat(commenter(client, base, "admin")).containsExactly("admin", "Demo Administrator");
|
||||
assertThat(commenter(client, base, null)).as("no choice falls back to the collab.dev.user default").containsExactly("admin", "Demo Administrator");
|
||||
assertThat(commenter(client, base, "nobody")).as("an unknown cookie falls back to the default").containsExactly("admin", "Demo Administrator");
|
||||
assertThat(CollaborationDevServer.selectedUser()).isEqualTo("admin");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Post a comment as the browser holding the given user cookie; return who the server says wrote it.
|
||||
*******************************************************************************/
|
||||
private static List<String> commenter(HttpClient client, String base, String cookieUser) throws Exception
|
||||
{
|
||||
String cookie = "sessionUUID=" + UUID.randomUUID() + (cookieUser == null ? "" : "; collab_dev_user=" + cookieUser);
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/qqq/v1/qbit/collaboration/records/person/1/comments")).timeout(Duration.ofSeconds(20))
|
||||
.header("Cookie", cookie).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"body\":\"Who am I?\"}")).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
var json = new ObjectMapper().readTree(response.body());
|
||||
return (List.of(json.get("authorUserId").asText(), json.get("authorDisplayName").asText()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The demo places Collaboration inside Admin, and the admin demo user can navigate to it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void collaborationIsInTheAppTreeUnderAdmin() throws Exception
|
||||
{
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
CollaborationDevServer.MemoryStorage.clear();
|
||||
QInstance instance = CollaborationDevServer.buildInstance();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, CollaborationDevServer.session("admin"));
|
||||
var tree = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction()
|
||||
.execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput()).getAppTree();
|
||||
var admin = tree.stream().filter(node -> node.getName().equals("admin")).findFirst();
|
||||
assertThat(admin).as("admin sees the Admin app").isPresent();
|
||||
assertThat(admin.get().getChildren()).extracting(node -> node.getName()).contains("collaboration");
|
||||
assertThat(tree).extracting(node -> node.getName()).doesNotContain("collaboration");
|
||||
QContext.init(instance, CollaborationDevServer.session("alice"));
|
||||
var aliceTree = new com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction()
|
||||
.execute(new com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput()).getAppTree();
|
||||
assertThat(aliceTree).extracting(node -> node.getName()).contains("sales").doesNotContain("admin", "collaboration");
|
||||
}
|
||||
finally
|
||||
{
|
||||
PresenceRegistry.close(instance);
|
||||
RealtimeBus.close(instance);
|
||||
QContext.clear();
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* 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.email;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
|
||||
import com.kingsrook.qbits.collaboration.notify.NoOpNotifier;
|
||||
import com.kingsrook.qbits.collaboration.notify.Notifications;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
|
||||
import com.kingsrook.qbits.collaboration.sharing.SharingService;
|
||||
import com.kingsrook.qbits.collaboration.tasks.TasksService;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailQBitProducer;
|
||||
import com.kingsrook.qbits.email.metadata.EmailTables;
|
||||
import com.kingsrook.qbits.email.permissions.EmailPermissions;
|
||||
import com.kingsrook.qbits.email.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Collaboration notifications through a real qbit-email outbox (memory backend).
|
||||
*******************************************************************************/
|
||||
class EmailCollaborationNotifierTest extends PersonalServiceFixture
|
||||
{
|
||||
private static final Map<String, QSession> sessions = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sessions for the recipients, so access is re-checked as them.
|
||||
*******************************************************************************/
|
||||
public static class TestSessions implements UserSessionProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The recipient own session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<QSession> sessionForUser(String user)
|
||||
{
|
||||
return (Optional.ofNullable(sessions.get(user)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipients bob and eve can read the document and have addresses.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void configure() throws Exception
|
||||
{
|
||||
sessions.clear();
|
||||
for(String user : List.of("alice", "bob", "eve"))
|
||||
{
|
||||
sessions.put(user, new QSession().withUser(new QUser().withIdReference(user)).withPermissions("document.read", "document.task").withSecurityKeyValue("tenant", "a"));
|
||||
}
|
||||
config.withUserSessionProvider(new QCodeReference(TestSessions.class));
|
||||
CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "bob").withValue("displayName", "Bob").withValue("email", "bob@example.org"));
|
||||
CollaborationRecords.insert(CollaborationTables.USER, new QRecord().withValue("userId", "eve").withValue("displayName", "Eve").withValue("email", "eve@example.org"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the email qbit in the test instance.
|
||||
*******************************************************************************/
|
||||
private void withEmail() throws Exception
|
||||
{
|
||||
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withPublicBaseUrl("https://app.example.org")
|
||||
.withSecretResolver(name -> "k")).produce(instance);
|
||||
QContext.getQSession().withPermissions(EmailPermissions.ALL);
|
||||
SystemWrites.insertOne(EmailTables.PROVIDER, new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP").withValue("host", "127.0.0.1")
|
||||
.withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All outbox messages.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> messages() throws Exception
|
||||
{
|
||||
return (SystemWrites.query(EmailTables.MESSAGE, new QQueryFilter()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notification of a category for a recipient.
|
||||
*******************************************************************************/
|
||||
private CollaborationNotification notification(String recipient, CollaborationNotification.Category category, String eventId)
|
||||
{
|
||||
return (new CollaborationNotification(recipient, "You were mentioned in a comment", "secret comment body", category, "document", "1",
|
||||
"https://app.example.org/app/document/1", eventId, "alice"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With the email qbit present and no notifier configured, a mention becomes one
|
||||
** NOTIFICATION message with the key, link and record, and no comment body; a
|
||||
** repeat is a duplicate.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mentionQueuesOneMessageAndRepeatIsDuplicate() throws Exception
|
||||
{
|
||||
withEmail();
|
||||
assertThat(config.getNotifier()).isNull();
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
|
||||
assertThat(messages()).singleElement().satisfies(message ->
|
||||
{
|
||||
assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION");
|
||||
assertThat(message.getValueString("toAddresses")).contains("bob@example.org").doesNotContain("eve@example.org");
|
||||
assertThat(message.getValueString("idempotencyKey")).isEqualTo("collaboration:mention:7:bob");
|
||||
assertThat(message.getValueString("linkedTableName")).isEqualTo("document");
|
||||
assertThat(message.getValueString("linkedRecordId")).isEqualTo("1");
|
||||
assertThat(message.getValueString("senderQbit")).isEqualTo("qbit-collaboration");
|
||||
assertThat(message.getValueString("subject")).contains("alice mentioned you on a record in Documents");
|
||||
assertThat(message.getValueString("subject") + message.getValueString("textBody") + message.getValueString("htmlBody")).doesNotContain("secret comment body");
|
||||
assertThat(message.getValueString("htmlBody")).contains("https://app.example.org/app/document/1");
|
||||
});
|
||||
assertThat(SystemWrites.query(EmailTables.TEMPLATE, new QQueryFilter())).extracting(t -> t.getValueString("name")).contains("collaboration_mention");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each recipient gets their own message; the share and task kinds use their
|
||||
** own templates and key kinds; follow digests send no email.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void oneMessagePerRecipientAndKinds() throws Exception
|
||||
{
|
||||
withEmail();
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.SHARE, "3"));
|
||||
Notifications.send(notification("eve", CollaborationNotification.Category.SHARE, "3"));
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.TASK, "9:remindAt"));
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.FOLLOW, "1"));
|
||||
assertThat(messages()).extracting(m -> m.getValueString("idempotencyKey"))
|
||||
.containsExactlyInAnyOrder("collaboration:share:3:bob", "collaboration:share:3:eve", "collaboration:task:9:remindAt:bob");
|
||||
assertThat(messages()).allSatisfy(m -> assertThat(m.getValueString("toAddresses")).doesNotContain(","));
|
||||
assertThat(SystemWrites.query(EmailTables.TEMPLATE, new QQueryFilter())).extracting(t -> t.getValueString("name"))
|
||||
.contains("collaboration_share", "collaboration_task_reminder");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured resolver replaces the directory; no address means no email.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void resolverAndMissingAddress() throws Exception
|
||||
{
|
||||
withEmail();
|
||||
config.withRecipientEmailResolver(user -> "eve".equals(user) ? "other@example.org" : null);
|
||||
Notifications.send(notification("eve", CollaborationNotification.Category.MENTION, "1"));
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "2"));
|
||||
assertThat(messages()).singleElement().satisfies(m -> assertThat(m.getValueString("toAddresses")).contains("other@example.org"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real shares and task assignments carry the event id and actor into the email.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sharesAndTasksThroughTheServices() throws Exception
|
||||
{
|
||||
withEmail();
|
||||
new SharingService().share("document", 1, List.of("bob"), "private message");
|
||||
new TasksService().create("document", 1, "Private task title", "private notes", "bob", null, null);
|
||||
assertThat(messages()).hasSize(2).allSatisfy(m ->
|
||||
{
|
||||
assertThat(m.getValueString("subject") + m.getValueString("textBody") + m.getValueString("htmlBody")).doesNotContain("private", "Private");
|
||||
assertThat(m.getValueString("idempotencyKey")).matches("collaboration:(share|task):\\d+:bob");
|
||||
});
|
||||
assertThat(messages().getFirst().getValueString("subject")).startsWith("alice shared");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without the email qbit in the instance nothing is sent and no receipt runs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void withoutEmailQbitNothingHappens() throws Exception
|
||||
{
|
||||
boolean[] receipt = {false};
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"), () -> receipt[0] = true);
|
||||
assertThat(receipt[0]).isFalse();
|
||||
assertThat(EmailCollaborationNotifier.isAvailable(instance)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A configured notifier always wins over the email default.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configuredNotifierWins() throws Exception
|
||||
{
|
||||
withEmail();
|
||||
config.withNotifier(new QCodeReference(NoOpNotifier.class));
|
||||
Notifications.send(notification("bob", CollaborationNotification.Category.MENTION, "7"));
|
||||
assertThat(messages()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,8 @@ import com.kingsrook.qbits.collaboration.CollaborationSchema;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
@@ -257,27 +254,6 @@ class RecentsServiceTest extends PersonalServiceFixture
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts use the current user's owner lock and admins see real distinct users.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeSummaryCounts() throws Exception
|
||||
{
|
||||
at(START);
|
||||
login("alice", CollaborationPermissions.VIEW);
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
FavoritesService.star("RECORD", "document", "1");
|
||||
RecentsService.recordView("TABLE", "document", null);
|
||||
login("bob", CollaborationPermissions.VIEW);
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
RecentsService.recordView("TABLE", "document", null);
|
||||
assertThat(CollaborationHome.render(START)).contains("<h3>Favorites</h3><p>Rows: 1</p><p>Distinct users: 1", "<h3>Recently viewed</h3><p>Rows: 1</p><p>Distinct users: 1");
|
||||
admin();
|
||||
assertThat(CollaborationHome.render(START)).contains("<h3>Favorites</h3><p>Rows: 3</p><p>Distinct users: 2", "<h3>Recently viewed</h3><p>Rows: 2</p><p>Distinct users: 2");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Freeze the service clock, including exact boundary cases.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user