diff --git a/CHANGELOG.md b/CHANGELOG.md index 7077d51..bc503b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,9 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Added - Repository scaffold; feature settings and people tables. +- Phase-b favorites and recents on QQQ 4.1.0-RC.5: visible `collaboration_favorite` and `collaboration_recent` tables, target-type possible values, unique personal targets and audit disabled for high-volume data. +- `CollaborationSecurity` owner locks based on the session user's ID reference, with admin all-access keys and automatic initialization before enforced table actions and in services/home. No collaboration permission is required to manage one's own rows. +- `FavoritesService` and `FavoriteEntry`: idempotent star/unstar, reorder/folders, refreshed labels, batched record visibility checks, unavailable-target redaction and Next UI paths. +- `RecentsService`: persisted one-minute throttling, visible newest-first lists (up to 50), and hidden daily `collaborationTrimRecents` scheduling with configurable per-user retention (default 200). +- Home row/distinct-user summaries, PostgreSQL 15+ unique indexes that treat null record IDs as equal, and memory-backend security/service/retention tests. +- UI API endpoints (§10.2) deferred to a follow-up PR after QQQ's UI API extension point ([QRun-IO/qqq#1128](https://github.com/QRun-IO/qqq/pull/1128)) is released; no unreleased core classes are required. diff --git a/README.md b/README.md index 90f1558..5c1d6a7 100644 --- a/README.md +++ b/README.md @@ -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:** the phase-a repository scaffold, feature settings and people tables, and the Collaboration app home with real session-scoped counts. Feature services in phases b to i are planned; stored settings are not yet applied as runtime overrides and people are not yet populated automatically. +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, and the Collaboration app home with real session-scoped counts. Phases c to i are planned; stored settings are not yet applied as runtime overrides and people are not yet populated automatically. Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md). @@ -8,8 +8,8 @@ Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/ - Java 21. - QQQ 4.1.0-RC.5 or newer compatible 4.1 release. The build pins RC.5. -- A configured QQQ backend supporting the table operations. PostgreSQL is the intended persistent store; tests use the memory backend. -- Later phases and runtime overrides need the core contracts from the QQQ collaboration PRs. This scaffold uses string enum ids matching the spec until those contracts are released. +- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use the memory backend. +- Later phases and runtime overrides need the core contracts from the QQQ collaboration PRs. Favorites and recents use only APIs released in RC.5. String enum ids match the spec until the core collaboration enums are released. ## Install @@ -44,28 +44,61 @@ Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation |---|---| | `collaboration_feature_setting` (Feature Settings) | Scope type (INSTANCE, APP, TABLE), scope name, one of the 12 record features, enabled flag, settings JSON stored as TEXT, permission mode. Unique on scope type + scope name + feature; FIELD-level audit. | | `collaboration_user` (People) | Unique session user ID, display name, email, avatar URL, last seen timestamp and active flag. Service-managed, read-only to users. | +| `collaboration_favorite` (Favorites) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `sortOrder`, optional `folder`. Unique on user + type + name + record ID. Owner-locked, audit off. | +| `collaboration_recent` (Recently Viewed) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `lastViewedAt`, `viewCount`. Same unique key and owner lock; audit off. | -Both 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. +All four 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. 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 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. 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. ## Permissions | Permission | Behavior | |---|---| -| `collaboration.view` | Read both tables, the app and home. | -| `collaboration.admin` | Write feature settings; also grants read access. Does not permit direct People writes. | +| `collaboration.view` | Read Feature Settings, People, the app and home. Not required for personal favorites or recents. | +| `collaboration.admin` | Write feature settings, read the app/home, access all users' favorite/recent rows, and run recents retention. Does not permit direct People writes. | | `collaboration.fields.manage` | Reserved constant for the later custom-field designer. | | `collaboration.tags.manage` | Reserved constant for later tag management. | These use the reference qbit's custom `QPermissionRules` checker. Extra permissions are constants, not automatically registered catalog entries. Configure permission grants in your host. Home count actions explicitly enforce permissions and honor record security locks. Hosts should enable `qInstance.setEnforcePermissionsInCoreActions(true)` for direct model API calls as well as middleware checks. +Personal tables use `PermissionLevel.NOT_PROTECTED` with a `READ_AND_WRITE` owner lock on `userId`. Every signed-in user can manage their own rows. `CollaborationSecurity` derives `collaboration_user` from `QSession.getUser().getIdReference()` and gives `collaboration_user_all_access` to admins. Its custom permission checker initializes keys before enforced table actions; services and the home also call `ensureSessionKeys`. Hosts may call `applyToSession(qInstance, session)` after authentication. Refreshing keys also removes revoked admin access. System sessions are untouched; create scheduler system sessions after the full instance is registered so QQQ includes the all-access key. + +Favorites and recents are omitted from app nav sections. They remain normal, addressable QQQ tables, including for admin inspection. The header and palette will use the services through the follow-up API. These two personal tables intentionally skip audit logging. Personal service methods explicitly filter the current owner even when called by an admin; they never elevate the session. Use standard table actions for admin-wide management. + +## Favorites and recents services + +Call these static methods inside the caller's `QContext`: + +```java +FavoritesService.star("RECORD", "order", "123"); +FavoritesService.star("TABLE", "order", null); +List favorites = FavoritesService.list(); +FavoritesService.reorder(List.of(12, 8), Map.of(12, "Work")); +FavoritesService.unstar("RECORD", "order", "123"); + +RecentsService.recordView("RECORD", "order", "123"); +List recents = RecentsService.list(50); +``` + +`FavoritesService` and `FavoriteEntry` are in `com.kingsrook.qbits.collaboration.favorites`; `RecentsService` is in `com.kingsrook.qbits.collaboration.recents`. Target types are `TABLE`, `APP`, `RECORD`, `PROCESS`, `REPORT` and `SAVED_VIEW`. Only `RECORD` accepts a non-null record ID. For `SAVED_VIEW`, `targetName` is the saved view ID; the instance must contain a readable core `savedView` table and the view's target table must also be readable. + +- Star and unstar are idempotent. New stars append to the existing sort order. Reorder moves the supplied IDs first and preserves the order of omitted favorites; folder entries may be null to clear a folder. All supplied IDs must belong to the current user. +- Targets are checked using core table/app/process/report permissions; record targets use `GetAction` and the session's record locks. Invisible and missing targets both fail with `Not found`. +- Lists refresh labels, using one batched record query per target table per batch. Favorites retain unavailable entries as `available=false`, `label="Unavailable"`, `href=null`; the cached label is never returned. Unstar remains possible after access is lost. Recents drop unavailable entries and refill from older rows up to the requested limit (1–50). +- `FavoriteEntry` contains `id`, `targetType`, `targetName`, `recordId`, `label`, `folder`, `sortOrder`, `available` and `href`. Recents use the same target DTO in newest-first order; their folder and sort order are null. `viewCount` and `lastViewedAt` remain available on the underlying recent table. +- Hrefs mirror Next's accepted flat routes: `/app//`, `/app/
//`, `/app//`, and `/app/
/savedView//`. Segments are URL-encoded. This avoids reconstructing nested app paths; trailing slashes support dotted process names. +- Record views upsert a unique personal target. If its persisted `lastViewedAt` is less than 60 seconds ago, nothing is written; otherwise its timestamp, cached label and count update. The hidden backend-only `collaborationTrimRecents` process retains the newest `recentsPerUser` targets independently for each user, breaking timestamp ties by ID. + +The UI API endpoints in spec §10.2 will arrive in a **follow-up PR once QQQ's UI API extension point ([QRun-IO/qqq#1128](https://github.com/QRun-IO/qqq/pull/1128)) is released**. This PR adds no endpoint or unreleased core dependency. Runtime feature overrides and the record-feature summary SPI also await released core contracts; the home counts are implemented now. + ## Configuration | Property | Default | Meaning | |---|---|---| -| `backendName` | Required | Existing QQQ backend used by both tables. | -| `schedulerName` | None | Reserved for scheduled work in later phases; unused now. | +| `backendName` | Required | Existing QQQ backend used by all four tables. | +| `schedulerName` | None | Existing scheduler for the hidden daily recents trim process (86400-second interval and initial delay). Without it, the process is available for explicit admin/system execution. | +| `recentsPerUser` | `200` | Positive retention count per user. Enforced by the trim process. | | `appName` | `collaboration` | Metadata name of the app. | | `parentAppName` | None | Optional existing parent, such as `admin`. | | `adminApp` | `true` | Set false for tables without the app and widget. | @@ -76,7 +109,7 @@ These use the reference qbit's custom `QPermissionRules` checker. Extra permissi 2. For PostgreSQL, generate `CollaborationSchema.postgresScript(config)` and apply it with the host's migration tool. It creates tables and unique indexes, never executes DDL, and does not migrate existing schemas. QQQ owns date/default values on writes. 3. Grant `collaboration.view` and `collaboration.admin` as appropriate; wire the host's audit persistence for FIELD audit events. 4. Validate the assembled instance. Navigate to Collaboration for Feature Settings and People. Expect empty tables until settings or trusted directory integrations provide rows. -5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. This phase installs no endpoints, scheduler jobs or host-table overlays. +5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. Favorites and recents services can be used now. This phase installs no endpoints or host-table overlays; recents scheduling is opt-in. ## Build diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e4fae98..56ecd2e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,11 +2,11 @@ The authoritative design is [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md` ([develop document](https://github.com/QRun-IO/qqq/blob/develop/docs/COLLABORATION.md)). -Collaboration adds metadata-driven record features to QQQ: attachments, tags, custom fields, comments and mentions, presence, sharing, favorites, follow, timeline, recents, tasks and assignment. Core owns security, query and runtime metadata contracts; this qbit owns visible `collaboration_*` tables and feature services. This scaffold implements the phase-a configuration, feature settings and people tables, and a home with real counts. The override provider awaits released core contracts; phases b to i remain planned. The table below is the full roadmap from the specification, not a list of shipped functionality. +Collaboration adds metadata-driven record features to QQQ: attachments, tags, custom fields, comments and mentions, presence, sharing, favorites, follow, timeline, recents, tasks and assignment. Core owns security, query and runtime metadata contracts; this qbit owns visible `collaboration_*` tables and feature services. Implemented: phase-a configuration, feature settings and people tables, plus phase-b favorites/recents tables, services, owner locks, retention and home counts against QQQ 4.1.0-RC.5. The phase-b UI API endpoints await the released extension point from QRun-IO/qqq#1128. The override and record-feature summary providers await released core contracts; phases c to i remain planned. The table below is the full roadmap from the specification, not a list of shipped functionality. | Phase | Core | Qbit | Next UI | |---|---|---|---| -| a. Contracts | `RecordFeaturesMetaData` + resolver + frontend publication; `RecordReference`, `RecordReferenceTableMetaData` and the security rules of §3.2 (table filter, mirrored locks, parent check, post-filter, cleanup hook); feature permissions in the catalog; `RecordFeatureSummaryProviderInterface` and `GET /table/{t}/{pk}/features`; `RuntimeMetaDataProviderInterface` and overlay (generation only, no fields yet); `RecordAccessCheckerInterface`, `UserSessionProviderInterface` | repo scaffold, qbit config, `collaboration_feature_setting`, `collaboration_user`, override provider, home skeleton | types for `RecordFeaturesV1` | +| a. Contracts | `RecordFeaturesMetaData` + resolver + frontend publication; `RecordReference`, `RecordReferenceTableMetaData` and the security rules of §3.2 (table filter, mirrored locks, parent check, post-filter, cleanup hook); feature permissions in the catalog; `RecordFeatureSummaryProviderInterface` and `GET /table/{t}/{pk}/features`; `RuntimeMetaDataProviderInterface` and overlay (generation only, no fields yet); `RecordAccessCheckerInterface`, `UserSessionProviderInterface` | repo scaffold, qbit config, `collaboration_feature_setting`, `collaboration_user`, override provider, home skeleton | types for `RecordFeaturesV1` | | b. Favorites, recents, palette | | favorites, recents, endpoints, summary provider | star in record and table headers, favorites menu, palette favorites group, server recents | | c. Attachments | | attachments with object storage, previews and thumbnails, delete action | Files tab, upload, preview | | d. Tags | `QSubQuery`; virtual field criteria expander in query, count, aggregate and search; `CONTAINS_ALL`; `#tag` search | tags, vocabulary modes, bulk actions, filter field | tag chips, Tags filter and column, `#tag` in palette and search | diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java index a1749f5..29e6fae 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java @@ -41,6 +41,7 @@ public class CollaborationQBitConfig implements QBitConfig private String appName = "collaboration"; private String parentAppName; private Boolean adminApp = true; + private Integer recentsPerUser = 200; @@ -199,6 +200,37 @@ public class CollaborationQBitConfig implements QBitConfig + /******************************************************************************* + ** Maximum retained recent targets per user. + *******************************************************************************/ + public Integer getRecentsPerUser() + { + return (recentsPerUser); + } + + + + /******************************************************************************* + ** Configure retention; validated when the producer is installed. + *******************************************************************************/ + public void setRecentsPerUser(Integer recentsPerUser) + { + this.recentsPerUser = recentsPerUser; + } + + + + /******************************************************************************* + ** Fluent retention configuration. + *******************************************************************************/ + public CollaborationQBitConfig withRecentsPerUser(Integer recentsPerUser) + { + setRecentsPerUser(recentsPerUser); + return (this); + } + + + /******************************************************************************* ** Validate the configured backend and app placement. *******************************************************************************/ @@ -209,6 +241,11 @@ public class CollaborationQBitConfig implements QBitConfig { assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors); } + assertCondition(recentsPerUser != null && recentsPerUser > 0, "recentsPerUser must be positive", errors); + if(schedulerName != null) + { + assertCondition(instance.getScheduler(schedulerName) != null, "Unknown Collaboration scheduler: " + schedulerName, errors); + } if(Boolean.TRUE.equals(adminApp)) { assertCondition(StringUtils.hasContent(appName), "appName is required when adminApp is enabled", errors); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java index d327d92..2c7b4f3 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java @@ -24,6 +24,7 @@ package com.kingsrook.qbits.collaboration; import com.kingsrook.qbits.collaboration.app.CollaborationHome; import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.recents.TrimRecentsProcess; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; @@ -124,6 +125,8 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer names = key.getFieldNames().stream().map(name -> table.getField(name).getBackendName()).toList(); - statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_" + table.getName() + "_" + index + " ON " + table.getName() + " (" + String.join(", ", names) + ")"); + statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_" + table.getName() + "_" + index + " ON " + table.getName() + " (" + String.join(", ", names) + ")" + + (key.getFieldNames().contains("recordId") ? " NULLS NOT DISTINCT" : "")); } } return (String.join(";\n", statements) + ";\n"); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSecurity.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSecurity.java new file mode 100644 index 0000000..4b457e7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationSecurity.java @@ -0,0 +1,135 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration; + + +import java.util.List; +import 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.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; + + +/******************************************************************************* + ** Personal tables require no collaboration.view grant: NOT_PROTECTED plus a + ** READ_AND_WRITE owner lock lets every signed-in user manage their own rows. + ** Administrators receive the all-access key. The permission checker initializes + ** keys before enforced core actions (RC.5 has no pre-query customizer); services + ** and the home also ensure keys, like Alerts. Hosts may call applyToSession + ** after authentication. System sessions are left untouched. + *******************************************************************************/ +public class CollaborationSecurity implements CustomPermissionChecker +{ + public static final String KEY_USER = "collaboration_user"; + public static final String KEY_USER_ALL_ACCESS = "collaboration_user_all_access"; + + + + /******************************************************************************* + ** Register the owner key and its administrator override. + *******************************************************************************/ + public static List buildSecurityKeyTypes() + { + return (List.of(new QSecurityKeyType().withName(KEY_USER).withAllAccessKeyName(KEY_USER_ALL_ACCESS))); + } + + + + /******************************************************************************* + ** Apply ownership to reads and writes, including changes to the owner field. + *******************************************************************************/ + public static RecordSecurityLock ownerLock(String fieldName) + { + return (new RecordSecurityLock().withSecurityKeyType(KEY_USER).withFieldName(fieldName) + .withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE)); + } + + + + /******************************************************************************* + ** Replace our keys so a changed user or revoked admin permission cannot linger. + *******************************************************************************/ + public static void applyToSession(QInstance instance, QSession session) + { + if(instance == null || session == null || session instanceof QSystemUserSession) + { + return; + } + if(session.getSecurityKeyValues() != null) + { + session.getSecurityKeyValues().remove(KEY_USER); + session.getSecurityKeyValues().remove(KEY_USER_ALL_ACCESS); + } + String userId = userId(session); + if(userId != null && !userId.isBlank()) + { + session.withSecurityKeyValue(KEY_USER, userId); + if(session.hasPermission(CollaborationPermissions.ADMIN)) + { + session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true); + } + } + } + + + + /******************************************************************************* + ** Cheaply refresh the two keys on every entry point; no metadata scan needed. + *******************************************************************************/ + public static void ensureSessionKeys(QInstance instance, QSession session) + { + applyToSession(instance, session); + } + + + + /******************************************************************************* + ** Authentication's stable user reference, never a display name or session id. + *******************************************************************************/ + public static String userId(QSession session) + { + return (session == null || session.getUser() == null ? null : session.getUser().getIdReference()); + } + + + + /******************************************************************************* + ** Initialize before direct query/get/count/aggregate/insert/update/delete. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + QSession session = QContext.getQSession(); + ensureSessionKeys(QContext.getQInstance(), session); + if(!(session instanceof QSystemUserSession) && (userId(session) == null || userId(session).isBlank())) + { + throw (new QPermissionDeniedException("Sign in to manage favorites and recents.")); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/PersonalRecords.java b/src/main/java/com/kingsrook/qbits/collaboration/PersonalRecords.java new file mode 100644 index 0000000..7e03982 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/PersonalRecords.java @@ -0,0 +1,179 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration; + + +import java.io.Serializable; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +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.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Shared current-session persistence. Explicit user filters keep personal service + ** calls scoped to the caller even when administrators hold the all-access key. + *******************************************************************************/ +public final class PersonalRecords +{ + /******************************************************************************* + ** Static operations only. + *******************************************************************************/ + private PersonalRecords() + { + } + + + + /******************************************************************************* + ** Require an authenticated owner before accepting a personal operation. + *******************************************************************************/ + public static String currentUserId() throws QPermissionDeniedException + { + CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession()); + String userId = CollaborationSecurity.userId(QContext.getQSession()); + if(userId == null || userId.isBlank()) + { + throw (new QPermissionDeniedException("Sign in to manage favorites and recents.")); + } + return (userId); + } + + + + /******************************************************************************* + ** Always constrain personal services to the current owner. + *******************************************************************************/ + public static QQueryFilter ownFilter() throws QPermissionDeniedException + { + return (new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, currentUserId())); + } + + + + /******************************************************************************* + ** Null record ids use IS_BLANK, not SQL equality with NULL. + *******************************************************************************/ + public static QQueryFilter targetFilter(String type, String name, String recordId) throws QPermissionDeniedException + { + QQueryFilter filter = ownFilter().withCriteria("targetType", QCriteriaOperator.EQUALS, type) + .withCriteria("targetName", QCriteriaOperator.EQUALS, name); + if(recordId == null) + { + filter.withCriteria("recordId", QCriteriaOperator.IS_BLANK); + } + else + { + filter.withCriteria("recordId", QCriteriaOperator.EQUALS, recordId); + } + return (filter); + } + + + + /******************************************************************************* + ** Enforce permissions independently of the host's core-action default. + *******************************************************************************/ + public static List query(String table, QQueryFilter filter) throws QException + { + QueryInput input = new QueryInput(table).withFilter(filter); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + return (new QueryAction().execute(input).getRecords()); + } + + + + /******************************************************************************* + ** Propagate per-record validation failures instead of reporting success. + *******************************************************************************/ + public static QRecord insert(String table, QRecord record) throws QException + { + InsertInput input = new InsertInput(table).withRecords(List.of(record)); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + QRecord result = new InsertAction().execute(input).getRecords().getFirst(); + checkErrors(List.of(result)); + return (result); + } + + + + /******************************************************************************* + ** Update only fields supplied by the service, using the current session. + *******************************************************************************/ + public static void update(String table, List records) throws QException + { + if(records.isEmpty()) + { + return; + } + UpdateInput input = new UpdateInput(table).withRecords(records); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + checkErrors(new UpdateAction().execute(input).getRecords()); + } + + + + /******************************************************************************* + ** Delete already selected ids; row locks are still enforced by DeleteAction. + *******************************************************************************/ + public static void delete(String table, List ids) throws QException + { + if(ids.isEmpty()) + { + return; + } + DeleteInput input = new DeleteInput(table).withPrimaryKeys(ids); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + checkErrors(new DeleteAction().execute(input).getRecordsWithErrors()); + } + + + + /******************************************************************************* + ** QQQ actions can return record errors without throwing. + *******************************************************************************/ + private static void checkErrors(List records) throws QException + { + if(records != null) + { + for(QRecord record : records) + { + if(record.getErrors() != null && !record.getErrors().isEmpty()) + { + throw (new QException("Could not save personal target: " + record.getErrors())); + } + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java b/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java new file mode 100644 index 0000000..2bfec25 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/TargetResolver.java @@ -0,0 +1,316 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration; + + +import java.io.Serializable; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +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.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.exceptions.QValueException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +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.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.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** Resolves targets only with released RC.5 APIs. Each list batches record label + ** refreshes by table, preserving table permissions and record security locks. + ** Next accepts the flat /app/name/ routes, including dotted process names. + *******************************************************************************/ +public final class TargetResolver +{ + private static final Set TYPES = Set.of("TABLE", "APP", "RECORD", "PROCESS", "REPORT", "SAVED_VIEW"); + + + + /******************************************************************************* + ** Static operations only. + *******************************************************************************/ + private TargetResolver() + { + } + + + + /******************************************************************************* + ** Validate target shape without requiring visibility (also used to unstar). + *******************************************************************************/ + public static String recordId(String type, String name, Serializable recordId) throws QException + { + String id = recordId == null ? null : recordId.toString(); + if(type == null || !TYPES.contains(type) || name == null || name.isBlank() + || ("RECORD".equals(type) ? id == null || id.isBlank() : id != null)) + { + throw (new QException("Invalid target")); + } + if("RECORD".equals(type)) + { + QTableMetaData table = QContext.getQInstance().getTable(name); + if(table != null && table.getPrimaryKeyField() != null) + { + try + { + id = ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), id).toString(); + } + catch(QValueException expected) + { + throw (new QException("Not found")); + } + } + } + return (id); + } + + + + /******************************************************************************* + ** Resolve one target for writes; missing and invisible have the same error. + *******************************************************************************/ + public static FavoriteEntry require(String type, String name, Serializable id) throws QException + { + String recordId = recordId(type, name, id); + QRecord row = new QRecord().withValue("targetType", type).withValue("targetName", name).withValue("recordId", recordId); + FavoriteEntry entry; + if("RECORD".equals(type)) + { + QRecord record = get(name, recordId); + entry = entry(row, record == null ? null : record.getRecordLabel(), record == null ? null : path(name) + encode(recordId) + "/"); + } + else + { + entry = metadataEntry(row); + } + if(!entry.available()) + { + throw (new QException("Not found")); + } + return (entry); + } + + + + /******************************************************************************* + ** One QueryAction per referenced record table; no cached labels escape. + *******************************************************************************/ + public static List refresh(List rows) throws QException + { + Map> idsByTable = new LinkedHashMap<>(); + for(QRecord row : rows) + { + if("RECORD".equals(row.getValueString("targetType"))) + { + idsByTable.computeIfAbsent(row.getValueString("targetName"), key -> new ArrayList<>()).add(row.getValueString("recordId")); + } + } + Map> recordsByTable = new HashMap<>(); + for(Map.Entry> group : idsByTable.entrySet()) + { + String table = group.getKey(); + Map records = new HashMap<>(); + recordsByTable.put(table, records); + if(canReadTable(table)) + { + QueryInput input = new QueryInput(table).withShouldGenerateDisplayValues(true) + .withFilter(new QQueryFilter().withCriteria(QContext.getQInstance().getTable(table).getPrimaryKeyField(), QCriteriaOperator.IN, group.getValue())); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + try + { + for(QRecord record : new QueryAction().execute(input).getRecords()) + { + records.put(record.getValueString(QContext.getQInstance().getTable(table).getPrimaryKeyField()), record); + } + } + catch(QPermissionDeniedException expected) + { + // Permission may have changed since the metadata check; fail closed. + } + } + } + List entries = new ArrayList<>(); + for(QRecord row : rows) + { + String table = row.getValueString("targetName"); + String id = row.getValueString("recordId"); + if("RECORD".equals(row.getValueString("targetType"))) + { + QRecord record = recordsByTable.get(table).get(id); + entries.add(entry(row, record == null ? null : record.getRecordLabel(), record == null ? null : path(table) + encode(id) + "/")); + } + else + { + entries.add(metadataEntry(row)); + } + } + return (entries); + } + + + + /******************************************************************************* + ** Metadata targets use the same permission helpers as core. Saved views also + ** require a visible savedView row and READ on the view's target table. + *******************************************************************************/ + private static FavoriteEntry metadataEntry(QRecord row) throws QException + { + QInstance instance = QContext.getQInstance(); + AbstractActionInput input = new AbstractActionInput(); + String name = row.getValueString("targetName"); + String label = null; + String href = null; + switch(row.getValueString("targetType")) + { + case "TABLE" -> + { + if(canReadTable(name)) + { + label = instance.getTable(name).getLabel(); + href = path(name); + } + } + case "APP" -> + { + if(PermissionsHelper.hasAppPermission(input, name)) + { + label = instance.getApp(name).getLabel(); + href = path(name); + } + } + case "PROCESS" -> + { + if(PermissionsHelper.hasProcessPermission(input, name)) + { + label = instance.getProcess(name).getLabel(); + href = path(name); + } + } + case "REPORT" -> + { + if(PermissionsHelper.hasReportPermission(input, name)) + { + label = instance.getReport(name).getLabel(); + href = path(name); + } + } + case "SAVED_VIEW" -> + { + QRecord view = get("savedView", name); + if(view != null && canReadTable(view.getValueString("tableName"))) + { + label = view.getValueString("label"); + href = path(view.getValueString("tableName")) + "savedView/" + encode(name) + "/"; + } + } + default -> + { + // Unknown persisted types are unavailable rather than exposing their cache. + } + } + return (entry(row, label, href)); + } + + + + /******************************************************************************* + ** Both table visibility and per-record security are checked by GetAction. + *******************************************************************************/ + private static QRecord get(String table, Serializable id) throws QException + { + if(!canReadTable(table)) + { + return (null); + } + GetInput input = new GetInput(table).withPrimaryKey(id).withShouldGenerateDisplayValues(true); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + try + { + return (new GetAction().execute(input).getRecord()); + } + catch(QPermissionDeniedException expected) + { + return (null); + } + } + + + + /******************************************************************************* + ** Guard existence before invoking the core table permission helper. + *******************************************************************************/ + private static boolean canReadTable(String table) + { + return (table != null && QContext.getQInstance().getTable(table) != null + && PermissionsHelper.hasTablePermission(new AbstractActionInput(), table, TablePermissionSubType.READ)); + } + + + + /******************************************************************************* + ** A missing href means unavailable; never substitute a cached label. + *******************************************************************************/ + private static FavoriteEntry entry(QRecord row, String label, String href) + { + return (new FavoriteEntry(row.getValueInteger("id"), row.getValueString("targetType"), row.getValueString("targetName"), + row.getValueString("recordId"), href == null ? "Unavailable" : label == null ? row.getValueString("targetName") : label, + row.getValueString("folder"), row.getValueInteger("sortOrder"), href != null, href)); + } + + + + /******************************************************************************* + ** Flat Next router path; trailing slash also supports dotted process names. + *******************************************************************************/ + private static String path(String name) + { + return ("/app/" + encode(name) + "/"); + } + + + + /******************************************************************************* + ** Encode a single route segment, including slash, hash and spaces in ids. + *******************************************************************************/ + private static String encode(String value) + { + return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java index 81e44c2..8286218 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java @@ -26,12 +26,19 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; +import com.kingsrook.qbits.collaboration.CollaborationSecurity; import com.kingsrook.qbits.collaboration.model.CollaborationTables; import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; 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.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate; +import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator; +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; @@ -100,6 +107,7 @@ public final class CollaborationHome *******************************************************************************/ public static String render(Instant now) throws QException { + CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession()); StringBuilder html = new StringBuilder("

Feature settings

"); long settings = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter()); if(settings == 0) @@ -128,7 +136,26 @@ public final class CollaborationHome { html.append("

No people yet: the directory will be filled by collaboration calls when the feature services are installed.

"); } - return (html.append("
").toString()); + html.append(""); + personalSummary(html, CollaborationTables.FAVORITE, "Favorites"); + personalSummary(html, CollaborationTables.RECENT, "Recently viewed"); + return (html.append("").toString()); + } + + + + /******************************************************************************* + ** Aggregate rows and distinct owners without loading high-volume personal data. + *******************************************************************************/ + private static void personalSummary(StringBuilder html, String table, String label) 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)); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + List results = new AggregateAction().execute(input).getResults(); + html.append("

").append(label).append("

Rows: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(rows)) + .append("

Distinct users: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(users)).append("

"); } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoriteEntry.java b/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoriteEntry.java new file mode 100644 index 0000000..f18a924 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoriteEntry.java @@ -0,0 +1,32 @@ +/* + * 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.favorites; + + +/******************************************************************************* + ** A freshly permission-checked target. Unavailable entries never expose the + ** cached label or a navigable href. Also used for the ordered recents list. + *******************************************************************************/ +public record FavoriteEntry(Integer id, String targetType, String targetName, String recordId, + String label, String folder, Integer sortOrder, Boolean available, String href) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java b/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java new file mode 100644 index 0000000..59811cd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/favorites/FavoritesService.java @@ -0,0 +1,153 @@ +/* + * 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.favorites; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.collaboration.PersonalRecords; +import com.kingsrook.qbits.collaboration.TargetResolver; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Current-user favorites. All actions preserve QContext; administrators use the + ** standard table screens for cross-user management, not these personal methods. + *******************************************************************************/ +public final class FavoritesService +{ + /******************************************************************************* + ** Static service. + *******************************************************************************/ + private FavoritesService() + { + } + + + + /******************************************************************************* + ** Idempotently star a visible target, appending it after existing favorites. + *******************************************************************************/ + public static void star(String targetType, String targetName, Serializable recordId) throws QException + { + String userId = PersonalRecords.currentUserId(); + FavoriteEntry target = TargetResolver.require(targetType, targetName, recordId); + QQueryFilter filter = PersonalRecords.targetFilter(targetType, targetName, target.recordId()); + if(!PersonalRecords.query(CollaborationTables.FAVORITE, filter).isEmpty()) + { + return; + } + List last = PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.ownFilter() + .withOrderBy(new QFilterOrderBy("sortOrder", false)).withLimit(1)); + Integer order = last.isEmpty() ? 0 : Math.addExact(last.getFirst().getValueInteger("sortOrder"), 1); + QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName) + .withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("sortOrder", order); + try + { + PersonalRecords.insert(CollaborationTables.FAVORITE, row); + } + catch(QException e) + { + if(PersonalRecords.query(CollaborationTables.FAVORITE, filter).isEmpty()) + { + throw (e); + } + // A concurrent star won the unique-key race; the requested state exists. + } + } + + + + /******************************************************************************* + ** Removing a star does not require continued access to its target. + *******************************************************************************/ + public static void unstar(String targetType, String targetName, Serializable recordId) throws QException + { + String id = TargetResolver.recordId(targetType, targetName, recordId); + List rows = PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.targetFilter(targetType, targetName, id)); + PersonalRecords.delete(CollaborationTables.FAVORITE, rows.stream().map(row -> row.getValue("id")).toList()); + } + + + + /******************************************************************************* + ** Ordered favorites with freshly checked labels and availability. + *******************************************************************************/ + public static List list() throws QException + { + return (TargetResolver.refresh(PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.ownFilter() + .withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id"))))); + } + + + + /******************************************************************************* + ** Move the supplied ids first, preserving order of omitted favorites. Folder + ** values may be null to clear them. Validate all ids before writing anything. + *******************************************************************************/ + public static void reorder(List ids, Map folders) throws QException + { + List rows = PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.ownFilter() + .withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id"))); + Set owned = new HashSet<>(rows.stream().map(row -> row.getValueInteger("id")).toList()); + if(ids == null || folders == null || new HashSet<>(ids).size() != ids.size() || !owned.containsAll(ids) || !owned.containsAll(folders.keySet())) + { + throw (new QException("Invalid favorite ids")); + } + for(String folder : folders.values()) + { + if(folder != null && folder.length() > 255) + { + throw (new QException("Folder is too long")); + } + } + List order = new ArrayList<>(ids); + for(QRecord row : rows) + { + Integer id = row.getValueInteger("id"); + if(!ids.contains(id)) + { + order.add(id); + } + } + List changes = new ArrayList<>(); + for(int i = 0; i < order.size(); i++) + { + Integer id = order.get(i); + QRecord change = new QRecord().withValue("id", id).withValue("sortOrder", i); + if(folders.containsKey(id)) + { + change.setValue("folder", folders.get(id)); + } + changes.add(change); + } + PersonalRecords.update(CollaborationTables.FAVORITE, changes); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java index e8c4e4a..2819889 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java @@ -25,13 +25,17 @@ package com.kingsrook.qbits.collaboration.model; import java.util.ArrayList; import java.util.List; import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; +import com.kingsrook.qbits.collaboration.CollaborationSecurity; import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; 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.code.QCodeReference; 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.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.QPossibleValue; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; @@ -48,6 +52,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; public final class CollaborationTables { public static final String FEATURE_SETTING = "collaboration_feature_setting"; + public static final String FAVORITE = "collaboration_favorite"; + public static final String RECENT = "collaboration_recent"; + public static final String TARGET_TYPE = "collaboration_target_type"; public static final String USER = "collaboration_user"; public static final String SCOPE_TYPE = "collaboration_scope_type"; public static final String FEATURE = "collaboration_feature"; @@ -70,6 +77,8 @@ public final class CollaborationTables public static List possibleValueSources() { return (List.of( + enumSource(TARGET_TYPE, "TABLE", "APP", "RECORD", "PROCESS", "REPORT", "SAVED_VIEW"), + QPossibleValueSource.newForTable(FAVORITE), QPossibleValueSource.newForTable(RECENT), enumSource(SCOPE_TYPE, "INSTANCE", "APP", "TABLE"), enumSource(FEATURE, "ATTACHMENTS", "TAGS", "CUSTOM_FIELDS", "COMMENTS", "PRESENCE", "SHARING", "FAVORITES", "FOLLOW", "TIMELINE", "RECENTS", "TASKS", "ASSIGNMENT"), @@ -127,7 +136,15 @@ public final class CollaborationTables { field.setIsEditable(false); } - List tables = List.of(settings, people); + QTableMetaData favorites = personal(config, FAVORITE, "Favorites", "star") + .withField(new QFieldMetaData("sortOrder", QFieldType.INTEGER).withLabel("Sort Order").withIsRequired(true).withDefaultValue(0)) + .withField(string("folder", "Folder", 255)) + .withSection(new QFieldSection("details", new QIcon("star"), Tier.T2, List.of("label", "sortOrder", "folder")).withLabel("Details")); + QTableMetaData recents = personal(config, RECENT, "Recently Viewed", "history") + .withField(new QFieldMetaData("lastViewedAt", QFieldType.DATE_TIME).withLabel("Last Viewed At").withIsRequired(true)) + .withField(new QFieldMetaData("viewCount", QFieldType.INTEGER).withLabel("View Count").withIsRequired(true).withDefaultValue(1)) + .withSection(new QFieldSection("details", new QIcon("history"), Tier.T2, List.of("label", "lastViewedAt", "viewCount")).withLabel("Details")); + List tables = List.of(settings, people, favorites, recents); for(QTableMetaData table : tables) { table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates")); @@ -152,6 +169,30 @@ public final class CollaborationTables + /******************************************************************************* + ** Personal high-volume data: owner locks replace a table-level permission. + ** Kept out of app navigation; the standard tables remain addressable by admins. + *******************************************************************************/ + private static QTableMetaData personal(CollaborationQBitConfig config, String name, String label, String icon) + { + return (base(config, name, label, icon) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED) + .withCustomPermissionChecker(new QCodeReference(CollaborationSecurity.class))) + .withRecordSecurityLock(CollaborationSecurity.ownerLock("userId")) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE)) + .withRecordLabelFormatAndFields("%s", "label") + .withField(string("userId", "User ID", 255).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)) + .withField(string("label", "Label", 1024)) + .withUniqueKey(new UniqueKey("userId", "targetType", "targetName", "recordId")) + .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, + List.of("id", "userId", "targetType", "targetName", "recordId")).withLabel("Identity"))); + } + + + /******************************************************************************* ** A bounded string field. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java new file mode 100644 index 0000000..890b846 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/recents/RecentsService.java @@ -0,0 +1,130 @@ +/* + * 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.recents; + + +import java.io.Serializable; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.collaboration.PersonalRecords; +import com.kingsrook.qbits.collaboration.TargetResolver; +import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Current-user recent targets, with persisted one-minute throttling. No process + ** cache or unreleased core contracts are needed. Lists drop unavailable targets. + *******************************************************************************/ +public final class RecentsService +{ + static Clock clock = Clock.systemUTC(); + + + + /******************************************************************************* + ** Static service. + *******************************************************************************/ + private RecentsService() + { + } + + + + /******************************************************************************* + ** Validate visibility, then insert or refresh at most once per minute. + *******************************************************************************/ + public static void recordView(String targetType, String targetName, Serializable recordId) throws QException + { + String userId = PersonalRecords.currentUserId(); + FavoriteEntry target = TargetResolver.require(targetType, targetName, recordId); + QQueryFilter filter = PersonalRecords.targetFilter(targetType, targetName, target.recordId()); + List existing = PersonalRecords.query(CollaborationTables.RECENT, filter); + Instant now = clock.instant(); + if(existing.isEmpty()) + { + QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName) + .withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("lastViewedAt", now).withValue("viewCount", 1); + try + { + PersonalRecords.insert(CollaborationTables.RECENT, row); + return; + } + catch(QException e) + { + existing = PersonalRecords.query(CollaborationTables.RECENT, filter); + if(existing.isEmpty()) + { + throw (e); + } + // A concurrent first view inserted this target. Apply normal throttling. + } + } + QRecord row = existing.getFirst(); + if(row.getValueInstant("lastViewedAt").plusSeconds(60).isAfter(now)) + { + return; + } + PersonalRecords.update(CollaborationTables.RECENT, List.of(new QRecord().withValue("id", row.getValue("id")) + .withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", target.label()))); + } + + + + /******************************************************************************* + ** Return at most 50 visible entries, newest first. Page until enough visible + ** targets are found, rather than letting unavailable rows consume the limit. + *******************************************************************************/ + public static List list(Integer limit) throws QException + { + if(limit == null || limit < 1 || limit > 50) + { + throw (new QException("Limit must be between 1 and 50")); + } + List result = new ArrayList<>(); + int skip = 0; + while(result.size() < limit) + { + List rows = PersonalRecords.query(CollaborationTables.RECENT, PersonalRecords.ownFilter() + .withOrderBy(new QFilterOrderBy("lastViewedAt", false)).withOrderBy(new QFilterOrderBy("id", false)).withSkip(skip).withLimit(50)); + for(FavoriteEntry entry : TargetResolver.refresh(rows)) + { + if(entry.available() && result.size() < limit) + { + result.add(entry); + } + } + if(rows.size() < 50) + { + break; + } + skip += rows.size(); + } + return (result); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/recents/TrimRecentsProcess.java b/src/main/java/com/kingsrook/qbits/collaboration/recents/TrimRecentsProcess.java new file mode 100644 index 0000000..bd406cf --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/recents/TrimRecentsProcess.java @@ -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.recents; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; +import com.kingsrook.qbits.collaboration.CollaborationSecurity; +import com.kingsrook.qbits.collaboration.PersonalRecords; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.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.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.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.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.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; + + +/******************************************************************************* + ** Hidden daily retention job. Runs with its caller's session, which must be a + ** scheduler system session or collaboration admin; never elevates a user. + *******************************************************************************/ +public class TrimRecentsProcess implements BackendStep, CustomPermissionChecker +{ + public static final String NAME = "collaborationTrimRecents"; + + + + /******************************************************************************* + ** Always register the backend-only process; scheduling is opt-in. + *******************************************************************************/ + public static QProcessMetaData metadata(CollaborationQBitConfig config) + { + QProcessMetaData process = new QProcessMetaData().withName(NAME).withLabel("Trim recents").withIsHidden(true).withIcon(new QIcon("history")) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION) + .withPermissionBaseName("collaboration").withCustomPermissionChecker(new QCodeReference(TrimRecentsProcess.class))) + .withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(TrimRecentsProcess.class))); + if(config.getSchedulerName() != null) + { + process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(86400).withInitialDelaySeconds(86400)); + } + return (process); + } + + + + /******************************************************************************* + ** Hidden is a presentation setting, so explicitly guard execution as well. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + QSession session = QContext.getQSession(); + if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(CollaborationPermissions.ADMIN))) + { + throw (new QPermissionDeniedException("This needs the collaboration.admin permission.")); + } + } + + + + /******************************************************************************* + ** Keyset-page users, then delete overflow in bounded batches. Re-query each + ** user's newest N before every batch, avoiding offsets over deleted rows. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + checkPermissionsThrowing(input, null); + CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession()); + CollaborationQBitConfig config = (CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream() + .filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig(); + String previousUser = null; + while(true) + { + QQueryFilter nextUser = new QQueryFilter().withOrderBy(new QFilterOrderBy("userId")).withLimit(1); + if(previousUser != null) + { + nextUser.withCriteria("userId", QCriteriaOperator.GREATER_THAN, previousUser); + } + List next = PersonalRecords.query(CollaborationTables.RECENT, nextUser); + if(next.isEmpty()) + { + break; + } + previousUser = next.getFirst().getValueString("userId"); + while(true) + { + List overflow = PersonalRecords.query(CollaborationTables.RECENT, new QQueryFilter() + .withCriteria("userId", QCriteriaOperator.EQUALS, previousUser) + .withOrderBy(new QFilterOrderBy("lastViewedAt", false)).withOrderBy(new QFilterOrderBy("id", false)) + .withSkip(config.getRecentsPerUser()).withLimit(500)); + if(overflow.isEmpty()) + { + break; + } + List ids = new ArrayList<>(overflow.stream().map(row -> row.getValue("id")).toList()); + PersonalRecords.delete(CollaborationTables.RECENT, ids); + } + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java index 2800784..0c2cd6a 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java @@ -85,7 +85,7 @@ class CollaborationTest instance.setEnforcePermissionsInCoreActions(true); config = new CollaborationQBitConfig().withBackendName("memory"); new CollaborationQBitProducer().withQBitConfig(config).produce(instance); - QContext.init(instance, new QSession().withPermissions(CollaborationPermissions.ADMIN)); + QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("admin")).withPermissions(CollaborationPermissions.ADMIN)); } @@ -177,7 +177,7 @@ 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(2); + assertThat(headless.getTables()).hasSize(4); assertThat(headless.getApps()).isEmpty(); assertThat(headless.getWidgets()).isEmpty(); } @@ -255,7 +255,7 @@ class CollaborationTest seed(CollaborationTables.USER, person("visible", now), person("hidden", now)); instance.addSecurityKeyType(new QSecurityKeyType().withName("person").withAllAccessKeyName("allPeople")); instance.getTable(CollaborationTables.USER).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("person").withFieldName("userId")); - QContext.init(instance, new QSession().withPermissions(CollaborationPermissions.VIEW).withSecurityKeyValue("person", "visible")); + 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"); instance.setEnforcePermissionsInCoreActions(false); QContext.getQSession().setPermissions(new java.util.HashSet<>()); diff --git a/src/test/java/com/kingsrook/qbits/collaboration/FavoritesServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/FavoritesServiceTest.java new file mode 100644 index 0000000..ffe9b6a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/FavoritesServiceTest.java @@ -0,0 +1,347 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration; + + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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.customizers.TableCustomizerInterface; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +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.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +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.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Behavioral and security acceptance for favorites, including label batching. + *******************************************************************************/ +class FavoritesServiceTest extends PersonalServiceFixture +{ + /******************************************************************************* + ** Both record and null-record-id targets are idempotent and append in order. + *******************************************************************************/ + @Test + void starAndUnstarAreIdempotent() throws Exception + { + FavoritesService.star("RECORD", "document", "1"); + FavoritesService.star("RECORD", "document", "1"); + FavoritesService.star("TABLE", "document", null); + FavoritesService.star("TABLE", "document", null); + assertThat(FavoritesService.list()).extracting(FavoriteEntry::label).containsExactly("First document", "Documents"); + assertThat(FavoritesService.list()).extracting(FavoriteEntry::sortOrder).containsExactly(0, 1); + assertThat(FavoritesService.list()).extracting(FavoriteEntry::href).containsExactly("/app/document/1/", "/app/document/"); + FavoritesService.unstar("RECORD", "document", "1"); + FavoritesService.unstar("RECORD", "document", "1"); + FavoritesService.unstar("TABLE", "document", null); + FavoritesService.unstar("TABLE", "document", null); + assertThat(FavoritesService.list()).isEmpty(); + } + + + + /******************************************************************************* + ** Missing, locked and table-denied records expose exactly the same error. + *******************************************************************************/ + @Test + void invisibleAndMissingAreIndistinguishable() throws Exception + { + for(String id : List.of("3", "999")) + { + assertThatThrownBy(() -> FavoritesService.star("RECORD", "document", id)).isInstanceOf(QException.class).hasMessage("Not found"); + } + assertThatThrownBy(() -> FavoritesService.star("TABLE", "missing", null)).hasMessage("Not found"); + instance.setEnforcePermissionsInCoreActions(false); + QContext.getQSession().getPermissions().remove("document.read"); + assertThatThrownBy(() -> FavoritesService.star("RECORD", "document", "1")).hasMessage("Not found"); + assertThatThrownBy(() -> FavoritesService.star("TABLE", "document", null)).hasMessage("Not found"); + assertThat(FavoritesService.list()).isEmpty(); + } + + + + /******************************************************************************* + ** Refresh uses one query, no gets; lock changes redact both label and href. + *******************************************************************************/ + @Test + void refreshesLabelsInOneQueryAndRedactsRevokedRecords() throws Exception + { + FavoritesService.star("RECORD", "document", "1"); + FavoritesService.star("RECORD", "document", "2"); + new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", 1).withValue("title", "Renamed")))); + ReadCounter.queries = 0; + ReadCounter.gets = 0; + instance.getTable("document").withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(ReadCounter.class)); + assertThat(FavoritesService.list()).extracting(FavoriteEntry::label).containsExactly("Renamed", "Second document"); + assertThat(ReadCounter.queries).isEqualTo(1); + assertThat(ReadCounter.gets).isZero(); + QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b")); + assertThat(FavoritesService.list()).allSatisfy(entry -> + { + assertThat(entry.available()).isFalse(); + assertThat(entry.label()).isEqualTo("Unavailable"); + assertThat(entry.href()).isNull(); + }); + FavoritesService.unstar("RECORD", "document", "1"); + assertThat(FavoritesService.list()).hasSize(1); + } + + + + /******************************************************************************* + ** Direct table reads/writes initialize keys and enforce both sides of a lock. + *******************************************************************************/ + @Test + void ownershipAndAdminAccess() throws Exception + { + FavoritesService.star("TABLE", "document", null); + Integer aliceId = FavoritesService.list().getFirst().id(); + login("bob"); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.FAVORITE)).getRecords()).isEmpty(); + assertThat(new GetAction().execute(new GetInput(CollaborationTables.FAVORITE).withPrimaryKey(aliceId)).getRecord()).isNull(); + assertThat(new CountAction().execute(new CountInput(CollaborationTables.FAVORITE)).getCount()).isZero(); + FavoritesService.star("TABLE", "document", null); + QRecord spoof = new QRecord().withValue("userId", "alice").withValue("targetType", "TABLE").withValue("targetName", "other").withValue("sortOrder", 0); + assertThat(new InsertAction().execute(new InsertInput(CollaborationTables.FAVORITE).withRecords(List.of(spoof))).getRecords().getFirst().getErrors()).isNotEmpty(); + Integer bobId = FavoritesService.list().getFirst().id(); + assertThat(new UpdateAction().execute(new UpdateInput(CollaborationTables.FAVORITE).withRecords(List.of(new QRecord().withValue("id", bobId).withValue("userId", "alice")))) + .getRecords().getFirst().getErrors()).isNotEmpty(); + admin(); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.FAVORITE)).getRecords()).hasSize(2); + assertThat(FavoritesService.list()).isEmpty(); + QContext.getQSession().getPermissions().remove(CollaborationPermissions.ADMIN); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.FAVORITE)).getRecords()).isEmpty(); + assertThat(QContext.getQSession().getSecurityKeyValues()).doesNotContainKey(CollaborationSecurity.KEY_USER_ALL_ACCESS); + } + + + + /******************************************************************************* + ** Reordering validates all ownership before writing and can clear a folder. + *******************************************************************************/ + @Test + void reorderAndFolders() throws Exception + { + FavoritesService.star("RECORD", "document", "1"); + FavoritesService.star("RECORD", "document", "2"); + List ids = FavoritesService.list().stream().map(FavoriteEntry::id).toList(); + FavoritesService.reorder(List.of(ids.get(1)), Map.of(ids.get(1), "Work")); + assertThat(FavoritesService.list()).extracting(FavoriteEntry::id).containsExactly(ids.get(1), ids.get(0)); + assertThat(FavoritesService.list().getFirst().folder()).isEqualTo("Work"); + Map clear = new HashMap<>(); + clear.put(ids.get(1), null); + FavoritesService.reorder(List.of(), clear); + assertThat(FavoritesService.list().getFirst().folder()).isNull(); + assertThatThrownBy(() -> FavoritesService.reorder(List.of(ids.get(0), ids.get(0)), Map.of())).hasMessage("Invalid favorite ids"); + assertThatThrownBy(() -> FavoritesService.reorder(List.of(), Map.of(ids.get(0), "x".repeat(256)))).hasMessage("Folder is too long"); + login("bob"); + assertThatThrownBy(() -> FavoritesService.reorder(ids, Map.of())).hasMessage("Invalid favorite ids"); + assertThatThrownBy(() -> FavoritesService.reorder(List.of(), Map.of(ids.get(0), "stolen"))).hasMessage("Invalid favorite ids"); + } + + + + /******************************************************************************* + ** All metadata types use their core permissions, refresh labels and redact. + *******************************************************************************/ + @Test + void metadataTargetsAndRevocation() throws Exception + { + QPermissionRules rules = new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("targets"); + instance.addApp(new QAppMetaData().withName("work").withLabel("Work").withPermissionRules(rules)); + instance.addProcess(new QProcessMetaData().withName("document.export").withLabel("Export").withPermissionRules(rules)); + instance.addReport(new QReportMetaData().withName("report").withLabel("Report").withPermissionRules(rules)); + for(String type : List.of("APP", "PROCESS", "REPORT")) + { + String name = type.equals("APP") ? "work" : type.equals("PROCESS") ? "document.export" : "report"; + assertThatThrownBy(() -> FavoritesService.star(type, name, null)).hasMessage("Not found"); + assertThatThrownBy(() -> FavoritesService.star(type, "missing", null)).hasMessage("Not found"); + } + QContext.getQSession().withPermissions("targets.hasAccess"); + FavoritesService.star("APP", "work", null); + FavoritesService.star("PROCESS", "document.export", null); + FavoritesService.star("REPORT", "report", null); + FavoritesService.star("TABLE", "document", null); + assertThat(FavoritesService.list()).extracting(FavoriteEntry::label).containsExactly("Work", "Export", "Report", "Documents"); + assertThat(FavoritesService.list().get(1).href()).isEqualTo("/app/document.export/"); + instance.getApp("work").setLabel("Renamed app"); + assertThat(FavoritesService.list().getFirst().label()).isEqualTo("Renamed app"); + QContext.getQSession().getPermissions().clear(); + assertThat(FavoritesService.list()).allSatisfy(entry -> assertThat(entry.label()).isEqualTo("Unavailable")); + } + + + + /******************************************************************************* + ** Saved views are conditional on the core table and respect its row locks. + *******************************************************************************/ + @Test + void savedViews() throws Exception + { + assertThatThrownBy(() -> FavoritesService.star("SAVED_VIEW", "7", null)).hasMessage("Not found"); + instance.addTable(new QTableMetaData().withName("savedView").withBackendName("memory").withPrimaryKeyField("id") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)) + .withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("label", QFieldType.STRING)) + .withField(new QFieldMetaData("tableName", QFieldType.STRING)).withField(new QFieldMetaData("userId", QFieldType.STRING)) + .withRecordLabelFormatAndFields("%s", "label").withRecordSecurityLock(CollaborationSecurity.ownerLock("userId"))); + CollaborationSecurity.ensureSessionKeys(instance, QContext.getQSession()); + new InsertAction().execute(new InsertInput("savedView").withRecords(List.of(new QRecord().withValue("id", 7).withValue("label", "My view") + .withValue("tableName", "document").withValue("userId", "alice")))); + FavoritesService.star("SAVED_VIEW", "7", null); + assertThat(FavoritesService.list().getFirst().label()).isEqualTo("My view"); + assertThat(FavoritesService.list().getFirst().href()).isEqualTo("/app/document/savedView/7/"); + login("bob"); + assertThatThrownBy(() -> FavoritesService.star("SAVED_VIEW", "7", null)).hasMessage("Not found"); + login("alice"); + QContext.getQSession().getPermissions().remove("document.read"); + assertThat(FavoritesService.list().getFirst().label()).isEqualTo("Unavailable"); + } + + + + /******************************************************************************* + ** Integer aliases refer to one target; arbitrary string keys are URL-encoded. + *******************************************************************************/ + @Test + void canonicalRecordIdsAndEncodedPaths() throws Exception + { + FavoritesService.star("RECORD", "document", "01"); + FavoritesService.star("RECORD", "document", 1); + assertThat(FavoritesService.list()).extracting(FavoriteEntry::recordId).containsExactly("1"); + assertThat(FavoritesService.list().getFirst().available()).isTrue(); + FavoritesService.unstar("RECORD", "document", "001"); + assertThat(FavoritesService.list()).isEmpty(); + assertThatThrownBy(() -> FavoritesService.star("RECORD", "document", "invalid")).hasMessage("Not found"); + instance.addTable(new QTableMetaData().withName("named").withLabel("Named").withBackendName("memory").withPrimaryKeyField("id") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)) + .withField(new QFieldMetaData("id", QFieldType.STRING)).withRecordLabelFormatAndFields("%s", "id")); + new InsertAction().execute(new InsertInput("named").withRecords(List.of(new QRecord().withValue("id", "a/b #?")))); + FavoritesService.star("RECORD", "named", "a/b #?"); + assertThat(FavoritesService.list().getFirst().href()).isEqualTo("/app/named/a%2Fb%20%23%3F/"); + } + + + + /******************************************************************************* + ** Deletion has the same unavailable presentation as losing access. + *******************************************************************************/ + @Test + void deletedTargetsAndCrossUserWrites() throws Exception + { + FavoritesService.star("RECORD", "document", "1"); + FavoritesService.star("TABLE", "document", null); + Integer favoriteId = FavoritesService.list().getFirst().id(); + login("bob"); + assertThat(new UpdateAction().execute(new UpdateInput(CollaborationTables.FAVORITE) + .withRecords(List.of(new QRecord().withValue("id", favoriteId).withValue("folder", "stolen")))) + .getRecords().getFirst().getErrors()).isNotEmpty(); + assertThat(new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute( + new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput(CollaborationTables.FAVORITE).withPrimaryKeys(List.of(favoriteId))).getDeletedRecordCount()).isZero(); + admin(); + assertThatThrownBy(() -> FavoritesService.star("RECORD", "document", "3")).hasMessage("Not found"); + login("alice"); + PersonalRecords.delete("document", List.of(1)); + assertThat(FavoritesService.list().getFirst().label()).isEqualTo("Unavailable"); + instance.getTables().remove("document"); + assertThat(FavoritesService.list()).allSatisfy(entry -> + { + assertThat(entry.available()).isFalse(); + assertThat(entry.label()).isEqualTo("Unavailable"); + assertThat(entry.href()).isNull(); + }); + } + + + + /******************************************************************************* + ** Bad target shapes and anonymous callers fail before storing data. + *******************************************************************************/ + @Test + void rejectsInvalidTargetsAndAnonymousSessions() + { + assertThatThrownBy(() -> FavoritesService.star("OTHER", "document", null)).hasMessage("Invalid target"); + assertThatThrownBy(() -> FavoritesService.star("RECORD", "document", null)).hasMessage("Invalid target"); + assertThatThrownBy(() -> FavoritesService.star("TABLE", "document", "1")).hasMessage("Invalid target"); + assertThatThrownBy(() -> FavoritesService.star("TABLE", " ", null)).hasMessage("Invalid target"); + QContext.init(instance, new QSession()); + assertThatThrownBy(() -> FavoritesService.list()).hasMessageContaining("Sign in"); + assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.FAVORITE))).hasMessageContaining("Sign in"); + } + + + + /******************************************************************************* + ** Count actual core reads rather than assuming an implementation batches. + *******************************************************************************/ + public static class ReadCounter implements TableCustomizerInterface + { + static int queries; + static int gets; + + + + /******************************************************************************* + ** Core calls this for both get and query, after enforcing record locks. + *******************************************************************************/ + @Override + public List postQuery(QueryOrGetInputInterface input, List rows) + { + if(input instanceof QueryInput) + { + queries++; + } + else + { + gets++; + } + return (rows); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java b/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java new file mode 100644 index 0000000..6e56b2a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/PersonalServiceFixture.java @@ -0,0 +1,124 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration; + + +import java.util.List; +import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +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.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Memory-backed personal services with a real, independently locked host table. + *******************************************************************************/ +public abstract class PersonalServiceFixture +{ + protected QInstance instance; + protected CollaborationQBitConfig config; + + + + /******************************************************************************* + ** Every test starts with two visible records and one invisible record. + *******************************************************************************/ + @BeforeEach + public void setUp() throws Exception + { + MemoryRecordStore.getInstance().reset(); + instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + instance.setEnforcePermissionsInCoreActions(true); + config = new CollaborationQBitConfig().withBackendName("memory"); + new CollaborationQBitProducer().withQBitConfig(config).produce(instance); + instance.addSecurityKeyType(new QSecurityKeyType().withName("tenant").withAllAccessKeyName("allTenants")); + instance.addTable(new QTableMetaData().withName("document").withLabel("Documents").withBackendName("memory").withPrimaryKeyField("id") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS)) + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("title", QFieldType.STRING)) + .withField(new QFieldMetaData("tenant", QFieldType.STRING)) + .withRecordLabelFormatAndFields("%s", "title") + .withRecordSecurityLock(new RecordSecurityLock().withFieldName("tenant").withSecurityKeyType("tenant") + .withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE))); + login("alice"); + QContext.getQSession().withSecurityKeyValue("allTenants", true); + InsertInput insert = new InsertInput("document").withRecords(List.of( + new QRecord().withValue("id", 1).withValue("title", "First document").withValue("tenant", "a"), + new QRecord().withValue("id", 2).withValue("title", "Second document").withValue("tenant", "a"), + new QRecord().withValue("id", 3).withValue("title", "Secret document").withValue("tenant", "b"))); + assertThat(new InsertAction().execute(insert).getRecords()).allSatisfy(row -> assertThat(row.getErrors()).isNullOrEmpty()); + login("alice"); + } + + + + /******************************************************************************* + ** No collaboration.view grant: personal services must still work. + *******************************************************************************/ + protected void login(String id, String... extraPermissions) + { + QContext.init(instance, new QSession().withUser(new QUser().withIdReference(id)).withPermissions("document.read", "document.write") + .withPermissions(extraPermissions).withSecurityKeyValue("tenant", "a")); + } + + + + /******************************************************************************* + ** Admin access is independent of ordinary host-table access. + *******************************************************************************/ + protected void admin() + { + login("admin", CollaborationPermissions.ADMIN); + } + + + + /******************************************************************************* + ** No static context or memory records survive a test. + *******************************************************************************/ + @AfterEach + public void tearDown() + { + QContext.clear(); + MemoryRecordStore.getInstance().reset(); + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java new file mode 100644 index 0000000..d9aa188 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/recents/RecentsServiceTest.java @@ -0,0 +1,298 @@ +/* + * 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.recents; + + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; +import com.kingsrook.qbits.collaboration.CollaborationQBitProducer; +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; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +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.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +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.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Persisted throttling, visible ordering, bounded retention and summary counts. + *******************************************************************************/ +class RecentsServiceTest extends PersonalServiceFixture +{ + private static final Instant START = Instant.parse("2026-10-13T12:00:00Z"); + + + + /******************************************************************************* + ** Do not leak an injected clock to another test. + *******************************************************************************/ + @AfterEach + void resetClock() + { + RecentsService.clock = Clock.systemUTC(); + } + + + + /******************************************************************************* + ** The exact 60-second boundary updates; 59 seconds does not write anything. + *******************************************************************************/ + @Test + void throttlesPerTargetAndUser() throws Exception + { + at(START); + RecentsService.recordView("RECORD", "document", "1"); + QRecord initial = rows().getFirst(); + at(START.plusSeconds(59)); + RecentsService.recordView("RECORD", "document", "1"); + assertThat(rows().getFirst().getValues()).isEqualTo(initial.getValues()); + at(START.plusSeconds(60)); + RecentsService.recordView("RECORD", "document", "1"); + assertThat(rows()).hasSize(1); + assertThat(rows().getFirst().getValueInteger("viewCount")).isEqualTo(2); + assertThat(rows().getFirst().getValueInstant("lastViewedAt")).isEqualTo(START.plusSeconds(60)); + RecentsService.recordView("RECORD", "document", "2"); + assertThat(rows()).hasSize(2); + login("bob"); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECENT)).getRecords()).isEmpty(); + RecentsService.recordView("RECORD", "document", "1"); + assertThat(rows()).hasSize(1); + assertThat(rows().getFirst().getValueInteger("viewCount")).isEqualTo(1); + admin(); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECENT)).getRecords()).hasSize(3); + assertThat(RecentsService.list(50)).isEmpty(); + } + + + + /******************************************************************************* + ** Null ids also upsert; visibility is checked even inside the throttle window. + *******************************************************************************/ + @Test + void tableUpsertAndInvisibleViews() throws Exception + { + at(START); + RecentsService.recordView("TABLE", "document", null); + RecentsService.recordView("TABLE", "document", null); + assertThat(rows()).hasSize(1); + at(START.plusSeconds(61)); + RecentsService.recordView("TABLE", "document", null); + assertThat(rows().getFirst().getValueInteger("viewCount")).isEqualTo(2); + assertThatThrownBy(() -> RecentsService.recordView("RECORD", "document", "3")).hasMessage("Not found"); + RecentsService.recordView("RECORD", "document", "1"); + QContext.getQSession().getPermissions().remove("document.read"); + assertThatThrownBy(() -> RecentsService.recordView("RECORD", "document", "1")).hasMessage("Not found"); + assertThat(RecentsService.list(50)).isEmpty(); + } + + + + /******************************************************************************* + ** Newest-first ordering is stable; locked rows are dropped before the limit. + *******************************************************************************/ + @Test + void limitOrderAndVisibility() throws Exception + { + at(START); + RecentsService.recordView("TABLE", "document", null); + at(START.plusSeconds(1)); + RecentsService.recordView("RECORD", "document", "1"); + at(START.plusSeconds(2)); + RecentsService.recordView("RECORD", "document", "2"); + assertThat(RecentsService.list(2)).extracting(FavoriteEntry::recordId).containsExactly("2", "1"); + assertThat(RecentsService.list(50)).hasSize(3); + QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b")); + assertThat(RecentsService.list(1)).extracting(FavoriteEntry::targetType).containsExactly("TABLE"); + assertThatThrownBy(() -> RecentsService.list(51)).hasMessage("Limit must be between 1 and 50"); + assertThatThrownBy(() -> RecentsService.list(0)).hasMessage("Limit must be between 1 and 50"); + assertThatThrownBy(() -> RecentsService.list(null)).hasMessage("Limit must be between 1 and 50"); + } + + + + /******************************************************************************* + ** A page full of unavailable rows must not hide older, accessible targets. + *******************************************************************************/ + @Test + void refillsAfterUnavailablePage() throws Exception + { + at(START); + RecentsService.recordView("TABLE", "document", null); + for(int i = 0; i < 51; i++) + { + PersonalRecords.insert(CollaborationTables.RECENT, new QRecord().withValue("userId", "alice").withValue("targetType", "TABLE") + .withValue("targetName", "removed" + i).withValue("label", "Old private label").withValue("lastViewedAt", START.plusSeconds(i + 1)).withValue("viewCount", 1)); + } + assertThat(RecentsService.list(50)).extracting(FavoriteEntry::label).containsExactly("Documents"); + } + + + + /******************************************************************************* + ** A real backend process keeps newest N independently for each owner. + *******************************************************************************/ + @Test + void trimProcessAndExecutionPermission() throws Exception + { + config.setRecentsPerUser(2); + for(String user : List.of("alice", "bob")) + { + login(user); + at(START); + RecentsService.recordView("RECORD", "document", "1"); + at(START.plusSeconds(1)); + RecentsService.recordView("RECORD", "document", "2"); + at(START.plusSeconds(2)); + RecentsService.recordView("TABLE", "document", null); + } + assertThatThrownBy(() -> new TrimRecentsProcess().run(new RunBackendStepInput(), new RunBackendStepOutput())).hasMessageContaining("collaboration.admin"); + assertThatThrownBy(() -> new RunProcessAction().execute(new RunProcessInput().withProcessName(TrimRecentsProcess.NAME))).hasMessageContaining("collaboration.admin"); + admin(); + new RunProcessAction().execute(new RunProcessInput().withProcessName(TrimRecentsProcess.NAME)); + assertThat(PersonalRecords.query(CollaborationTables.RECENT, new QQueryFilter())).hasSize(4); + for(String user : List.of("alice", "bob")) + { + login(user); + assertThat(RecentsService.list(50)).extracting(FavoriteEntry::recordId).containsExactly(null, "2"); + } + QSystemUserSession system = new QSystemUserSession(); + QContext.init(instance, system); + var before = new HashMap<>(system.getSecurityKeyValues()); + CollaborationSecurity.applyToSession(instance, system); + CollaborationSecurity.ensureSessionKeys(instance, system); + assertThat(system.getSecurityKeyValues()).isEqualTo(before); + config.setRecentsPerUser(1); + new RunProcessAction().execute(new RunProcessInput().withProcessName(TrimRecentsProcess.NAME)); + assertThat(PersonalRecords.query(CollaborationTables.RECENT, new QQueryFilter())).hasSize(2); + assertThat(QContext.getQSession()).isSameAs(system); + } + + + + /******************************************************************************* + ** The daily schedule and metadata remain valid without adding nav processes. + *******************************************************************************/ + @Test + void metadataSchemaAndConfiguration() throws Exception + { + assertThat(new CollaborationQBitConfig().getRecentsPerUser()).isEqualTo(200); + assertThat(instance.getProcess(TrimRecentsProcess.NAME).getIsHidden()).isTrue(); + assertThat(instance.getProcess(TrimRecentsProcess.NAME).getSchedule()).isNull(); + assertThatThrownBy(() -> config.withRecentsPerUser(0).validate(instance)).hasMessageContaining("recentsPerUser"); + assertThatThrownBy(() -> config.withRecentsPerUser(null).validate(instance)).hasMessageContaining("recentsPerUser"); + config.withRecentsPerUser(200); + assertThatThrownBy(() -> config.withSchedulerName("missing").validate(instance)).hasMessageContaining("scheduler"); + instance.addScheduler(new SimpleSchedulerMetaData().withName("daily")); + config.withSchedulerName("daily"); + config.validate(instance); + QProcessMetaData scheduled = TrimRecentsProcess.metadata(config); + assertThat(scheduled.getSchedule().getSchedulerName()).isEqualTo("daily"); + assertThat(scheduled.getSchedule().getRepeatSeconds()).isEqualTo(86400); + instance.getProcess(TrimRecentsProcess.NAME).setSchedule(scheduled.getSchedule()); + new QInstanceValidator().validate(instance); + for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT)) + { + assertThat(instance.getTable(table).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.NONE); + assertThat(instance.getTable(table).getPermissionRules().getLevel()).isEqualTo(PermissionLevel.NOT_PROTECTED); + assertThat(instance.getTable(table).getUniqueKeys().getFirst().getFieldNames()).containsExactly("userId", "targetType", "targetName", "recordId"); + assertThat(instance.getTable(table).getSections()).extracting(section -> section.getName()).containsExactly("identity", "details", "dates"); + } + assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName()).doesNotContain(CollaborationTables.FAVORITE, CollaborationTables.RECENT, TrimRecentsProcess.NAME); + assertThat(instance.getPossibleValueSource(CollaborationTables.TARGET_TYPE).getEnumValues()).hasSize(6); + assertThat(CollaborationSchema.postgresScript(config)).contains("CREATE TABLE IF NOT EXISTS collaboration_favorite", "CREATE TABLE IF NOT EXISTS collaboration_recent", + "(user_id, target_type, target_name, record_id) NULLS NOT DISTINCT", "last_viewed_at TIMESTAMPTZ NOT NULL", "view_count INTEGER NOT NULL"); + } + + + + /******************************************************************************* + ** 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("

Favorites

Rows: 1

Distinct users: 1", "

Recently viewed

Rows: 1

Distinct users: 1"); + admin(); + assertThat(CollaborationHome.render(START)).contains("

Favorites

Rows: 3

Distinct users: 2", "

Recently viewed

Rows: 2

Distinct users: 2"); + } + + + + /******************************************************************************* + ** Freeze the service clock, including exact boundary cases. + *******************************************************************************/ + private void at(Instant instant) + { + RecentsService.clock = Clock.fixed(instant, ZoneOffset.UTC); + } + + + + /******************************************************************************* + ** Inspect persisted rows for the current owner without altering their values. + *******************************************************************************/ + private List rows() throws Exception + { + return (PersonalRecords.query(CollaborationTables.RECENT, PersonalRecords.ownFilter())); + } +}