From 696f4f48ce1517733b956af1c0a1c91ac8778a8a Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 17:03:30 -0500 Subject: [PATCH] feat: tags with per-table vocabularies, approval modes and bulk tag actions collaboration_tag and collaboration_record_tag; FREE or APPROVED_ONLY per table from feature settings; normalized names, suggestions, usage counts; Approve, Merge and Delete tags record actions; Add tags / Remove tags bulk actions for configured host tables; TagFilterTranslator for the filter operators, to be wrapped in core's criteria expander once QRun-IO/qqq#1140 is released; tag activity on the timeline; the home Tags card. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + README.md | 65 ++- docs/DESIGN.md | 41 ++ .../CollaborationQBitConfig.java | 39 +- .../CollaborationQBitProducer.java | 5 + .../collaboration/CollaborationSchema.java | 3 +- .../collaboration/app/CollaborationHome.java | 25 +- .../attachments/AttachmentCleanup.java | 1 + .../model/CollaborationTables.java | 39 ++ .../model/ConversationChildren.java | 6 + .../permissions/CollaborationPermissions.java | 2 +- .../security/ParentRecordAccess.java | 14 +- .../qbits/collaboration/tags/TagEntry.java | 29 ++ .../tags/TagFilterTranslator.java | 126 +++++ .../collaboration/tags/TagProcesses.java | 316 ++++++++++++ .../qbits/collaboration/tags/TagSecurity.java | 104 ++++ .../qbits/collaboration/tags/TagsService.java | 463 ++++++++++++++++++ .../collaboration/CollaborationTest.java | 6 +- .../ConversationServicesTest.java | 4 +- .../qbits/collaboration/TagsServiceTest.java | 448 +++++++++++++++++ 20 files changed, 1731 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/tags/TagEntry.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/tags/TagFilterTranslator.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/tags/TagProcesses.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/tags/TagSecurity.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6d3eb..707495d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Added +- Phase-d tag vocabulary and record assignments on QQQ 4.1.0-RC.5; normalized names, FREE/APPROVED_ONLY policy resolved from system-read scoped feature settings, explicit global/manage permissions, parent-checked idempotent assignment, usage counts and TAGGED/UNTAGGED activity without follower notifications. +- Tags navigation, secured Record Tags child list, FIELD audit, label search, approved/scoped table PVS, streamed Approve/Merge/Delete and configured host Add/Remove actions, parent cleanup and real Tags home metrics. Bulk multi-select intent is exposed in picker metadata for the separate Next UI implementation. +- Standalone `TagFilterTranslator` for IN/CONTAINS_ALL/NOT_IN/IS_BLANK/IS_NOT_BLANK with a 10,000 matching-ID cap. Filter/quick-search integration lands after QRun-IO/qqq#1140 is released. +- Tag service, security, vocabulary, settings, schema, cap and streamed Review/Result tests; existing JaCoCo gates retained. + - Phase-e comments/mentions, one-level threads, edit revisions, author/admin soft deletion, resolve/reopen, ranked mention candidates and keyset pagination on QQQ 4.1.0-RC.5. - Comments, Comment History, Mentions, Followers, Activity and durable Follow Digests tables, child-list widgets, service-only writes, shared parent-table query restrictions, schema indexes and real home counts. - Lazy people-directory upserts with ten-minute throttling from comments/follow/favorites/recents/attachments entry points; bounded active-name/email search. diff --git a/README.md b/README.md index d9c5bcd..cc42043 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:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Tags (d) and phases f to i remain planned; stored settings are not yet applied as runtime overrides. +Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and standalone filter translation. Phases f to i remain planned; general runtime overrides are still deferred. TAGS mode settings are read directly by the service. Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md). @@ -218,3 +218,66 @@ Offline verification: `./mvnw -o -q verify` (Windows: `./mvnw.cmd -o -q verify`) ## License [Apache License 2.0](LICENSE). Copyright 2026 Kingsrook, LLC; see [NOTICE](NOTICE). + +## Tags + +Phase d builds against **QQQ 4.1.0-RC.5**. Register host actions with +`config.withTagActionTables(List.of("order"))`; `withDefaultTagMode("FREE")` is the +default. Set `collaboration_feature_setting.feature = TAGS` and JSON +`{"mode":"APPROVED_ONLY"}` to require approval. The service reads settings as the +system, in TABLE → preferred APP → INSTANCE order, falling back to config. The +first scope containing `mode` wins; `enabled` remains part of future general +feature resolution. Invalid policy JSON or mode fails closed. + +`TagsService` provides: + +- `vocabulary(table, query, limit)`: global and table vocabulary, approved tags plus + the caller's pending suggestions, excluding merged aliases; usage descending, + then label. Default limit 20, maximum 100. +- `create(tableOrNull, label, color)`: trimmed, lowercase, collapsed name of 1–60 + characters; the label retains its case. Duplicate names reuse the existing tag. + Per-table creation requires `.tag` or table EDIT and READ. FREE creates + approved tags; APPROVED_ONLY creates suggestions unless the caller holds + `collaboration.tags.manage`. Global creation always requires that manage grant. +- `tagsFor(table, recordId)` and `apply(table, recordId, add, remove)`: parent READ + and record locks are checked first. Writes also require the tag feature grant or + EDIT. Numeric values are tag IDs; strings are labels. Unknown add labels are + created in FREE; in APPROVED_ONLY they fail with + “Pick an approved tag (or suggest one)”. Pending tags cannot be applied by anyone. + Repeated changes are idempotent; actual changes write TAGGED/UNTAGGED activity, + recount usage, and do not notify followers. + +Tags is a main navigation table with FIELD auditing and native label search. +Record Tags is a secured child list. Both use service-managed writes. Standard +streamed **Approve tags**, **Merge tags**, and **Delete tags** actions require +`collaboration.tags.manage`; Delete Review counts distinct affected parents. +Merge deduplicates references and retains the source as an alias. Targets must be +approved and in the source scope or global; a global source cannot merge into a +single table's vocabulary. + +Host **Add tags** and **Remove tags** actions use the table-type +`collaboration_tag` PVS, filtered to approved, unmerged global or same-table tags. +Each record is rechecked during Review and execution. Input `tags` is a list of +integer IDs (comma-separated IDs are also accepted); `targetTagId` is a scalar. +The picker step exposes `EDIT_FORM` with `multiSelectFields = ["tags"]`. RC.5 has +no native multi-valued field type: rendering this component-value contract as a +multi-select belongs to the separate Next UI phase. The qbit tests exercise the +submitted arrays and real streamed Review/Result lifecycle. + +`AttachmentCleanup.onParentsDeleted` now also removes tag assignments and +recounts vocabulary usage. Hosts keep using this trusted callback until the core +cleanup registration contract ships. The home shows session-visible vocabulary +size, pending suggestions, and the five most-used approved tags. + +`TagFilterTranslator.recordIds(table, operatorName, tags)` supports `IN` (any), +`CONTAINS_ALL`, `NOT_IN` (none), `IS_BLANK`, and `IS_NOT_BLANK`. Every operator +returns matching visible parent IDs as strings, including complements. Exactly +10,000 results are allowed; larger results throw. Unknown tags match nothing; +empty IN matches nothing and empty CONTAINS_ALL/NOT_IN match all visible parents. +Numeric inputs are IDs, strings are normalized names; if a global and local tag +share a name, the global tag wins (use IDs to disambiguate). + +**Filter/quick-search integration lands after QRun-IO/qqq#1140 is released.** This +phase does not register a virtual filter field, core expander, `QSubQuery`, tag +column filler, or `#tag`/host-record quick-search provider. The standalone +translator deliberately imports none of those unreleased types. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index cd1875c..bbac562 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -41,3 +41,44 @@ See [README](../README.md#attachments) for the service contract, permissions and - **User-facing metadata.** Comments and admin-only Activity are main nav tables. Revisions, mentions and Followers are child lists; digest/follow tables are admin-browsable with HIDDEN deny behavior. Follow actions use generic streamed ETL Review/Result against any host key. The home reads real session-scoped comments/open-thread/follower counts. Standard QQQ writes remain available to host ESB listeners. The service API and configuration are documented in [README](../README.md#comments-follow-and-timeline). Endpoints remain deferred to QRun-IO/qqq#1128; no unreleased core dependency, runtime overlay, mirrored-lock implementation or realtime/UI delivery is introduced. + +## Phase-d implementation choices + +- **RC.5 boundary.** `tags/TagFilterTranslator` uses operator names, QQQ queries and + string parent IDs. It returns matching rows for all operators, so NOT_IN and + IS_BLANK enumerate the visible host-table complement. The 10,000 limit applies + to final matching IDs, not assignment count. It pages parent reads under the + caller's permissions and locks. Filter/quick-search integration lands after + QRun-IO/qqq#1140 is released; no unreleased core types or virtual fields are used. +- **Vocabulary versus assignments.** Tag names are unique per nullable scope; + PostgreSQL DDL emits NULLS NOT DISTINCT for nullable unique-key components. + Record Tags inherits `ParentTableRestriction` and the parent row customizer, + including the existing approximation for aggregate counts and short pages. + Vocabulary pickers need no collaboration.view grant: ordinary users see + approved tags and their own suggestions in readable scopes, while manage sees + all vocabulary for moderation. Direct writes are denied; services/actions + preserve normalization, approval and counts. +- **Policy lookup.** Settings are read in a temporary system context, retaining + the transaction and restoring the caller. TABLE overrides preferred APP (QQQ's + app-affinity path), then INSTANCE, then `defaultTagMode`. Missing mode inherits; + malformed JSON/invalid mode throws. General enabled/permission-mode runtime + overrides remain outside this phase. Name collision resolution prefers global + vocabulary; IDs provide an unambiguous selection. +- **Mutation boundaries.** Like existing services, writes participate in the + caller's QQQ transaction and serialize within the installed config's monitor. + Unique keys enforce assignment identity. Counts are recomputed from stored + links, including merge deduplication, deletion and trusted parent cleanup. + This is not a cross-pod lock: concurrent writers in separate instances need + host transaction/serialization coordination for exact denormalized counts. + Standard ETL runs with its process transaction. Activity is generated only for + actual apply/remove changes and never calls follower notification services. +- **Review and Result.** Actions derive operation and table from installed process + metadata. Management requires collaboration.tags.manage at both stages. Bulk + actions resolve approved PVS selections and recheck every parent and feature + permission at both stages. Review is read-only, counts already-satisfied rows, + and Delete reports distinct affected parents. Result reports successful and + failed records separately. The picker exposes multiSelectFields on EDIT_FORM + for the separate Next client; RC.5 itself has no native multi-value form field. +- **Presentation.** Tags is in the admin app nav; Record Tags is a child widget. + The home queries under the caller session and HTML-escapes labels. Native tag + label search metadata is present; host-record tag search awaits #1140. diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java index e8503ad..ef02284 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java @@ -40,6 +40,8 @@ public class CollaborationQBitConfig implements QBitConfig private QCodeReference notifier = new QCodeReference(NoOpNotifier.class); private QCodeReference userSessionSupplier = null; + private String defaultTagMode = "FREE"; + private List tagActionTables = List.of(); private List followActionTables = List.of(); private List followChangeTables = List.of(); private String backendName; @@ -250,8 +252,9 @@ public class CollaborationQBitConfig implements QBitConfig { assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors); } + assertCondition(List.of("FREE", "APPROVED_ONLY").contains(defaultTagMode == null ? "" : defaultTagMode), "defaultTagMode must be FREE or APPROVED_ONLY", errors); assertCondition(notifier != null, "notifier is required", errors); - for(List tables : List.of(followActionTables, followChangeTables)) + for(List tables : List.of(followActionTables, followChangeTables, tagActionTables)) { for(String table : tables) { @@ -439,4 +442,38 @@ public class CollaborationQBitConfig implements QBitConfig this.followChangeTables = followChangeTables; return (this); } + + /******************************************************************************* + ** Default vocabulary policy when no persisted override supplies a mode. + *******************************************************************************/ + public String getDefaultTagMode() + { + return (defaultTagMode); + } + + /******************************************************************************* + ** Configure the default tag policy. + *******************************************************************************/ + public CollaborationQBitConfig withDefaultTagMode(String mode) + { + this.defaultTagMode = mode; + return (this); + } + + /******************************************************************************* + ** Host tables receiving Add tags and Remove tags actions. + *******************************************************************************/ + public List getTagActionTables() + { + return (tagActionTables); + } + + /******************************************************************************* + ** Configure host tag actions. + *******************************************************************************/ + public CollaborationQBitConfig withTagActionTables(List tables) + { + this.tagActionTables = tables; + return (this); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java index 3aaaa54..3ebf4aa 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java @@ -137,6 +137,11 @@ 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) + ")" - + (key.getFieldNames().contains("recordId") ? " NULLS NOT DISTINCT" : "")); + + (key.getFieldNames().stream().anyMatch(name -> !Boolean.TRUE.equals(table.getField(name).getIsRequired())) ? " NULLS NOT DISTINCT" : "")); } } + statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_record_tag_tag ON collaboration_record_tag (tag_id)"); statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_parent ON collaboration_attachment (table_name, record_id)"); statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_parent ON collaboration_comment (table_name, record_id, last_activity_at, id)"); statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_created ON collaboration_comment (table_name, record_id, create_date)"); 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 3b14560..0145630 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java @@ -42,6 +42,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateRe import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; @@ -82,7 +83,7 @@ public final class CollaborationHome .withPermissionRules(CollaborationPermissions.permissionRules()) .withWidgets(List.of(HOME_WIDGET)) .withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum")) - .withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER))); + .withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER))); app.setParentAppName(config.getParentAppName()); return (app); } @@ -140,6 +141,7 @@ public final class CollaborationHome personalSummary(html, CollaborationTables.FAVORITE, "Favorites"); personalSummary(html, CollaborationTables.RECENT, "Recently viewed"); attachmentSummary(html); + tagSummary(html); html.append("

Comments this week

").append(count(CollaborationTables.COMMENT, new QQueryFilter() .withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS)) .withCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withCriteria("deletedAt", QCriteriaOperator.IS_BLANK))).append("

"); @@ -156,6 +158,27 @@ public final class CollaborationHome + /******************************************************************************* + ** Caller-filtered vocabulary totals and five most used active tags. + *******************************************************************************/ + private static void tagSummary(StringBuilder html) throws QException + { + QQueryFilter active = new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK); + html.append("

Tags

Vocabulary size: ").append(count(CollaborationTables.TAG, active)) + .append("

Pending suggestions: ").append(count(CollaborationTables.TAG, new QQueryFilter() + .withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK).withCriteria("approved", QCriteriaOperator.EQUALS, false))) + .append("

    "); + com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput input = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(CollaborationTables.TAG) + .withFilter(active.withCriteria("approved", QCriteriaOperator.EQUALS, true).withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(5)); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + for(com.kingsrook.qqq.backend.core.model.data.QRecord tag : new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(input).getRecords()) + { + html.append("
  • ").append(tag.getValueString("label").replace("&", "&").replace("<", "<").replace(">", ">")) + .append(": ").append(tag.getValueInteger("usageCount")).append("
  • "); + } + html.append("
"); + } + /******************************************************************************* ** Real table aggregates, restricted to readable parent tables. Parent locks ** are post-filtered on rows, so these aggregate totals are approximate. diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java index 8d344a6..cd35539 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java @@ -61,6 +61,7 @@ public class AttachmentCleanup { return; } + new com.kingsrook.qbits.collaboration.tags.TagsService().onParentsDeleted(table, ids); while(true) { List rows = AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table) 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 1cd0672..0f7b30a 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java @@ -58,6 +58,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; *******************************************************************************/ public final class CollaborationTables { + public static final String TAG = "collaboration_tag"; + public static final String RECORD_TAG = "collaboration_record_tag"; public static final String COMMENT = "collaboration_comment"; public static final String COMMENT_REVISION = "collaboration_comment_revision"; public static final String MENTION = "collaboration_mention"; @@ -91,6 +93,7 @@ public final class CollaborationTables public static List possibleValueSources() { return (List.of( + QPossibleValueSource.newForTable(TAG), QPossibleValueSource.newForTable(RECORD_TAG), QPossibleValueSource.newForTable(COMMENT), QPossibleValueSource.newForTable(COMMENT_REVISION), QPossibleValueSource.newForTable(MENTION), QPossibleValueSource.newForTable(FOLLOW), QPossibleValueSource.newForTable(ACTIVITY), QPossibleValueSource.newForTable(FOLLOW_DIGEST), enumSource(TARGET_TYPE, "TABLE", "APP", "RECORD", "PROCESS", "REPORT", "SAVED_VIEW"), QPossibleValueSource.newForTable(ATTACHMENT), QPossibleValueSource.newForTable(FAVORITE), QPossibleValueSource.newForTable(RECENT), @@ -161,6 +164,7 @@ public final class CollaborationTables .withSection(new QFieldSection("details", new QIcon("history"), Tier.T2, List.of("label", "lastViewedAt", "viewCount")).withLabel("Details")); List tables = new ArrayList<>(List.of(settings, people, favorites, recents, attachment(config))); tables.addAll(conversationTables(config)); + tables.addAll(tagTables(config)); for(QTableMetaData table : tables) { table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates")); @@ -343,4 +347,39 @@ public final class CollaborationTables .withRecordLabelFormatAndFields("%s", "id") .withSection(new QFieldSection("parent", new QIcon("link"), Tier.T2, List.of("tableName", "recordId", "parentDeleted")).withLabel("Parent"))); } + + /******************************************************************************* + ** Vocabulary is visible; assignments inherit parent restrictions and locks. + *******************************************************************************/ + private static List tagTables(CollaborationQBitConfig config) + { + QTableMetaData tags = base(config, TAG, "Tags", "label") + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED) + .withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.tags.TagSecurity.class))) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)) + .withSearchFields(List.of("label")) + .withRecordLabelFormatAndFields("%s", "label") + .withField(string("name", "Name", 60).withIsRequired(true)) + .withField(string("label", "Label", 255).withIsRequired(true)) + .withField(string("color", "Color", 64)) + .withField(string("tableName", "Table", 128)) + .withField(new QFieldMetaData("approved", QFieldType.BOOLEAN).withIsRequired(true)) + .withField(new QFieldMetaData("description", QFieldType.TEXT)) + .withField(string("createdByUserId", "Created By", 255).withIsRequired(true)) + .withField(new QFieldMetaData("usageCount", QFieldType.INTEGER).withIsRequired(true).withDefaultValue(0)) + .withField(new QFieldMetaData("mergedIntoTagId", QFieldType.INTEGER).withPossibleValueSourceName(TAG)) + .withUniqueKey(new UniqueKey("tableName", "name")) + .withSection(new QFieldSection("details", new QIcon("label"), Tier.T1, + List.of("id", "name", "label", "color", "tableName", "approved", "description", "createdByUserId", "usageCount", "mergedIntoTagId")).withLabel("Details")); + QTableMetaData links = referenced(config, RECORD_TAG, "Record Tags", "label") + .withField(new QFieldMetaData("tagId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(TAG)) + .withField(string("createdByUserId", "Created By", 255).withIsRequired(true)) + .withUniqueKey(new UniqueKey("tableName", "recordId", "tagId")) + .withSection(new QFieldSection("details", new QIcon("label"), Tier.T1, List.of("id", "tagId", "createdByUserId")).withLabel("Details")); + for(QTableMetaData table : List.of(tags, links)) + { + table.getFields().values().forEach(field -> field.setIsEditable(false)); + } + return (List.of(tags, links)); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java b/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java index d70cf47..d03dd7d 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/model/ConversationChildren.java @@ -48,6 +48,12 @@ public final class ConversationChildren *******************************************************************************/ public static void install(QInstance instance) { + QJoinMetaData tagJoin = new QJoinMetaData().withLeftTable(CollaborationTables.TAG).withRightTable(CollaborationTables.RECORD_TAG) + .withInferredName().withType(JoinType.ONE_TO_MANY).withJoinOns(List.of(new JoinOn("id", "tagId"))); + instance.addJoin(tagJoin); + instance.addWidget(ChildRecordListRenderer.widgetMetaDataBuilder(tagJoin).withLabel("Record Tags").withCanAddChildRecord(false).getWidgetMetaData()); + instance.getTable(CollaborationTables.TAG).withSection(new QFieldSection("recordTags", new QIcon("list"), Tier.T2) + .withLabel("Record Tags").withWidgetName(tagJoin.getName())); child(instance, CollaborationTables.COMMENT_REVISION, "Comment History", List.of(new JoinOn("id", "commentId"))); child(instance, CollaborationTables.MENTION, "Mentions", List.of(new JoinOn("id", "commentId"))); child(instance, CollaborationTables.FOLLOW, "Followers", List.of(new JoinOn("tableName", "tableName"), new JoinOn("recordId", "recordId"))); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/permissions/CollaborationPermissions.java b/src/main/java/com/kingsrook/qbits/collaboration/permissions/CollaborationPermissions.java index 258bd46..dcbd3a0 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/permissions/CollaborationPermissions.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/permissions/CollaborationPermissions.java @@ -29,7 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRule /******************************************************************************* - ** Full permission names; fields and tags management are reserved for later phases. + ** Full permission names; fields management is reserved for a later phase. *******************************************************************************/ public final class CollaborationPermissions { diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java index 7c1dfe3..652785a 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java @@ -94,10 +94,22 @@ public final class ParentRecordAccess public static void requireFeature(String tableName, Serializable recordId, String feature) throws QException { requireVisibleParent(tableName, recordId); + requireTableFeature(tableName, feature); + } + + /******************************************************************************* + ** Authorize vocabulary creation without requiring an arbitrary parent record. + *******************************************************************************/ + public static void requireTableFeature(String tableName, String feature) throws QException + { + if(!canReadTable(tableName)) + { + throw (new QNotFoundException("Not found")); + } QTableMetaData table = QContext.getQInstance().getTable(tableName); String base = table.getPermissionRules() == null ? null : table.getPermissionRules().getPermissionBaseName(); base = base == null || base.isBlank() ? tableName : base; - if(!QContext.getQSession().hasPermission(base + "." + feature) + if(!QContext.getQSession().hasPermission(tableName + "." + feature) && !QContext.getQSession().hasPermission(base + "." + feature) && !PermissionsHelper.hasTablePermission(new AbstractActionInput(), tableName, TablePermissionSubType.EDIT)) { throw (new QPermissionDeniedException("This needs the " + base + "." + feature + " permission or table EDIT.")); diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagEntry.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagEntry.java new file mode 100644 index 0000000..724580e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagEntry.java @@ -0,0 +1,29 @@ +/* + * 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.tags; + +/******************************************************************************* + ** A vocabulary entry or record chip, without exposing assignment rows. + *******************************************************************************/ +public record TagEntry(Integer id, String name, String label, String color, String tableName, Boolean approved, Integer usageCount) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagFilterTranslator.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagFilterTranslator.java new file mode 100644 index 0000000..9ed3b9e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagFilterTranslator.java @@ -0,0 +1,126 @@ +/* + * 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.tags; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.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.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Materialized tag filter logic independent of the unreleased core hooks in #1140. + ** Every operator returns matching caller-visible parent ids, including complements. + *******************************************************************************/ +public class TagFilterTranslator +{ + public static final int MAX_RECORD_IDS = 10000; + + /******************************************************************************* + ** IN means any tag, CONTAINS_ALL means every requested tag, NOT_IN means none. + ** Empty IN matches none; empty CONTAINS_ALL and NOT_IN match all visible rows. + *******************************************************************************/ + public List recordIds(String table, String op, Collection tags) throws QException + { + TagsService.requireTable(table); + if(!List.of("IN", "CONTAINS_ALL", "NOT_IN", "IS_BLANK", "IS_NOT_BLANK").contains(op)) + { + throw (new QException("Unsupported tag operator: " + op)); + } + Set requested = new HashSet<>(); + boolean missing = false; + if(!op.equals("IS_BLANK") && !op.equals("IS_NOT_BLANK")) + { + for(Serializable tag : tags == null ? List.of() : tags) + { + QQueryFilter filter = TagsService.vocabularyFilter(table).withCriteria("approved", QCriteriaOperator.EQUALS, true); + filter.withCriteria(tag instanceof Number ? "id" : "name", QCriteriaOperator.EQUALS, + tag instanceof Number ? tag : TagsService.normalize(tag == null ? null : tag.toString())); + List matches = CollaborationRecords.query(CollaborationTables.TAG, filter.withOrderBy(new QFilterOrderBy("id"))); + if(matches.isEmpty()) + { + missing = true; + } + else + { + QRecord match = matches.stream().filter(row -> row.getValue("tableName") == null).findFirst().orElse(matches.getFirst()); + requested.add(match.getValueInteger("id")); + } + } + } + Map> assignments = new HashMap<>(); + for(QRecord row : CollaborationRecords.query(CollaborationTables.RECORD_TAG, new QQueryFilter() + .withCriteria("tableName", QCriteriaOperator.EQUALS, table).withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false))) + { + assignments.computeIfAbsent(row.getValueString("recordId"), ignored -> new HashSet<>()).add(row.getValueInteger("tagId")); + } + List result = new ArrayList<>(); + String key = QContext.getQInstance().getTable(table).getPrimaryKeyField(); + for(int skip = 0; ; skip += 500) + { + QueryInput input = new QueryInput(table).withFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy(key)).withSkip(skip).withLimit(500)) + .withTransaction(QContext.getQBackendTransaction()); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + List parents = new QueryAction().execute(input).getRecords(); + for(QRecord parent : parents) + { + String id = parent.getValueString(key); + Set applied = assignments.getOrDefault(id, Set.of()); + boolean any = requested.stream().anyMatch(applied::contains); + boolean match = switch(op) + { + case "IN" -> any; + case "NOT_IN" -> !any; + case "CONTAINS_ALL" -> !missing && applied.containsAll(requested); + case "IS_BLANK" -> applied.isEmpty(); + default -> !applied.isEmpty(); + }; + if(match) + { + result.add(id); + if(result.size() > MAX_RECORD_IDS) + { + throw (new QException("Tag filter exceeds 10,000 matching record ids.")); + } + } + } + if(parents.size() < 500) + { + return (result); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagProcesses.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagProcesses.java new file mode 100644 index 0000000..04b7849 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagProcesses.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.tags; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.permissions.UseTablePermissionCustomPermissionChecker; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.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.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.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; + +/******************************************************************************* + ** Managed vocabulary and host bulk actions using the standard streamed ETL flow. + *******************************************************************************/ +public final class TagProcesses +{ + public static final String TAGS = "tags"; + public static final String TARGET = "targetTagId"; + + /******************************************************************************* + ** Metadata factory. + *******************************************************************************/ + private TagProcesses() + { + } + + /******************************************************************************* + ** Vocabulary record actions, never navigation entries. + *******************************************************************************/ + public static List management() + { + return (List.of(process(CollaborationTables.TAG, "Approve"), process(CollaborationTables.TAG, "Merge"), process(CollaborationTables.TAG, "Delete"))); + } + + /******************************************************************************* + ** Host record actions configured by tagActionTables. + *******************************************************************************/ + public static List processesFor(String table) + { + return (List.of(process(table, "Add"), process(table, "Remove"))); + } + + /******************************************************************************* + ** Approved scoped PVS inputs and explicit selection cardinality for clients. + *******************************************************************************/ + private static QProcessMetaData process(String table, String operation) + { + boolean bulk = !table.equals(CollaborationTables.TAG); + Map values = new HashMap<>(); + values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table); + values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table); + values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, operation + " tags on the selected records."); + QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values) + .withName("collaboration" + operation + "Tags" + (bulk ? "_" + table : "")).withLabel(operation + " tags").withTableName(table).withIcon(new QIcon("label")) + .withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(bulk + ? UseTablePermissionCustomPermissionChecker.build(table, TablePermissionSubType.READ) : new QCodeReference(TagSecurity.class))); + if(bulk || operation.equals("Merge")) + { + QQueryFilter filter = bulk ? TagsService.vocabularyFilter(table) : new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK); + filter.withCriteria("approved", QCriteriaOperator.EQUALS, true).withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")); + QFieldMetaData field = new QFieldMetaData(bulk ? TAGS : TARGET, QFieldType.INTEGER).withLabel(bulk ? "Tags" : "Target tag") + .withIsRequired(true).withPossibleValueSourceName(CollaborationTables.TAG).withPossibleValueSourceFilter(filter); + process.withStep(0, new QFrontendStepMetaData().withName("tags").withLabel(operation + " tags").withFormField(field) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM) + .withValue("multiSelectFields", new ArrayList<>(bulk ? List.of(TAGS) : List.of())))); + } + return (process); + } + + /******************************************************************************* + ** Operation comes from installed metadata, never caller-supplied values. + *******************************************************************************/ + private static String operation(RunBackendStepInput input) + { + return (input.getProcess().getName().substring("collaboration".length()).split("Tags")[0]); + } + + /******************************************************************************* + ** PVS submissions are ids; accept serialized comma lists as well as arrays. + *******************************************************************************/ + private static List tagIds(RunBackendStepInput input) throws QException + { + Serializable value = input.getValue(TAGS); + Collection values = value instanceof Collection collection ? collection : java.util.Arrays.asList(value == null ? new String[0] : value.toString().split(",")); + List ids = new ArrayList<>(); + try + { + for(Object id : values) + { + ids.add(Integer.valueOf(id.toString().strip())); + } + } + catch(NumberFormatException expected) + { + throw (new QException("Pick at least one approved tag.")); + } + if(ids.isEmpty()) + { + throw (new QException("Pick at least one approved tag.")); + } + return (ids.stream().distinct().toList()); + } + + /******************************************************************************* + ** Shared renderer for Review and Result language. + *******************************************************************************/ + private static ProcessSummaryLine line(Status status, String future, String past) + { + ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null); + line.setSingularFutureMessage(future); + line.setPluralFutureMessage(future); + line.setSingularPastMessage(past); + line.setPluralPastMessage(past); + return (line); + } + + /******************************************************************************* + ** Validate each record without mutation, reporting affected and unchanged rows. + *******************************************************************************/ + public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private ProcessSummaryLine selected; + private final ProcessSummaryLine skipped = line(Status.WARNING, "records cannot be processed (skipped)", "records skipped"); + private final ProcessSummaryLine unchanged = line(Status.INFO, "records already in the requested tag state", "records already in the requested tag state"); + private final ProcessSummaryLine affected = line(Status.INFO, "records will lose tag assignments", "records affected by tag deletion"); + private final Set affectedParents = new HashSet<>(); + + /******************************************************************************* + ** Parent and feature checks are made independently for every selected row. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String table = input.getProcess().getTableName(); + String op = operation(input); + boolean bulk = !table.equals(CollaborationTables.TAG); + List ids = bulk ? tagIds(input) : List.of(); + if(selected == null) + { + selected = line(Status.OK, "records: " + op + " " + (bulk ? ids.size() + " " : "") + "tags", "records: " + op + " tags completed"); + } + String key = QContext.getQInstance().getTable(table).getPrimaryKeyField(); + TagsService service = new TagsService(); + for(QRecord row : input.getRecords()) + { + Serializable id = row.getValue(key); + try + { + if(bulk) + { + ParentRecordAccess.requireFeature(table, id, "tag"); + service.resolve(table, ids, false); + Set present = new HashSet<>(service.tagsFor(table, id).stream().map(TagEntry::id).toList()); + if(op.equals("Add") ? present.containsAll(ids) : ids.stream().noneMatch(present::contains)) + { + unchanged.incrementCountAndAddPrimaryKey(id); + } + } + else + { + TagsService.requireManage(); + TagsService.requireTag(id); + if(op.equals("Merge")) + { + service.mergeTarget(id, input.getValue(TARGET)); + } + if(op.equals("Delete")) + { + for(QRecord link : TagsService.links(id)) + { + String parent = link.getValueString("tableName") + ":" + link.getValueString("recordId"); + if(affectedParents.add(parent)) + { + affected.incrementCountAndAddPrimaryKey(parent); + } + } + } + } + output.getRecords().add(row); + selected.incrementCountAndAddPrimaryKey(id); + } + catch(QException expected) + { + skipped.incrementCountAndAddPrimaryKey(id); + } + } + } + + /******************************************************************************* + ** Review totals are cumulative over streamed pages. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + if(selected != null) + { + selected.addSelfToListIfAnyCount(lines); + } + skipped.addSelfToListIfAnyCount(lines); + unchanged.addSelfToListIfAnyCount(lines); + affected.addSelfToListIfAnyCount(lines); + return (lines); + } + } + + /******************************************************************************* + ** Execute with fresh authorization; report actual successes and failures. + *******************************************************************************/ + public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final ProcessSummaryLine done = line(Status.OK, "records will be processed", "records processed"); + private final ProcessSummaryLine failed = line(Status.ERROR, "records cannot be processed", "records failed (retry required)"); + + /******************************************************************************* + ** The management service and parent service both recheck access at load time. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String table = input.getProcess().getTableName(); + String key = QContext.getQInstance().getTable(table).getPrimaryKeyField(); + TagsService service = new TagsService(); + for(QRecord row : input.getRecords()) + { + Serializable id = row.getValue(key); + try + { + switch(operation(input)) + { + case "Approve" -> service.approve(id); + case "Merge" -> service.merge(id, input.getValue(TARGET)); + case "Delete" -> service.delete(id); + case "Add" -> service.apply(table, id, tagIds(input), List.of()); + case "Remove" -> service.apply(table, id, List.of(), tagIds(input)); + default -> throw new QException("Unknown tag action."); + } + done.incrementCountAndAddPrimaryKey(id); + } + catch(QException expected) + { + failed.incrementCountAndAddPrimaryKey(id); + } + } + } + + /******************************************************************************* + ** Result retains review skips and reports load failures separately. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + done.addSelfToListIfAnyCount(lines); + failed.addSelfToListIfAnyCount(lines); + if(getTransformStep() instanceof Review review) + { + review.skipped.addSelfToListIfAnyCount(lines); + } + return (lines); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagSecurity.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagSecurity.java new file mode 100644 index 0000000..d10dead --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagSecurity.java @@ -0,0 +1,104 @@ +/* + * 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.tags; + +import com.kingsrook.qbits.collaboration.CollaborationSecurity; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +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.tables.QueryOrCountInputInterface; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +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.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; + +/******************************************************************************* + ** Vocabulary reads support pickers without collaboration.view. Management actions + ** require the explicit manage permission; direct writes must use the services. + *******************************************************************************/ +public class TagSecurity implements CustomPermissionChecker +{ + /******************************************************************************* + ** Restrict before query/count/aggregate and protect direct gets as well. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + if(metadata instanceof QProcessMetaData) + { + TagsService.requireManage(); + return; + } + if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput) + { + throw (new QPermissionDeniedException("Use the tag service actions.")); + } + if(TagsService.canManage()) + { + return; + } + String user = CollaborationSecurity.userId(QContext.getQSession()); + if(user == null) + { + throw (new QPermissionDeniedException("Sign in to view tags.")); + } + if(input instanceof QueryOrCountInputInterface query) + { + QQueryFilter original = query.getFilter(); + QQueryFilter filter = new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK) + .withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("tableName", QCriteriaOperator.IS_BLANK) + .withCriteria("tableName", QCriteriaOperator.IN, QContext.getQInstance().getTables().keySet().stream().filter(ParentRecordAccess::canReadTable).toList())) + .withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("approved", QCriteriaOperator.EQUALS, true) + .withCriteria("createdByUserId", QCriteriaOperator.EQUALS, user)); + if(original != null) + { + filter.withSubFilter(original).withLimit(original.getLimit()).withSkip(original.getSkip()); + filter.setOrderBys(original.getOrderBys()); + } + query.setFilter(filter); + } + if(input instanceof GetInput get) + { + try + { + QRecord row = TagsService.requireTag(get.getPrimaryKey()); + if((row.getValue("tableName") != null && !ParentRecordAccess.canReadTable(row.getValueString("tableName"))) + || (!Boolean.TRUE.equals(row.getValueBoolean("approved")) && !user.equals(row.getValueString("createdByUserId")))) + { + throw (new QPermissionDeniedException("Tag not available.")); + } + } + catch(QException expected) + { + throw (new QPermissionDeniedException("Tag not available.")); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java new file mode 100644 index 0000000..5527992 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/tags/TagsService.java @@ -0,0 +1,463 @@ +/* + * 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.tags; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import com.kingsrook.qbits.collaboration.CollaborationRecords; +import com.kingsrook.qbits.collaboration.PersonalRecords; +import com.kingsrook.qbits.collaboration.activity.ActivityWriter; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +import com.kingsrook.qqq.backend.core.context.CapturedContext; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +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.session.QSystemUserSession; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; + +/******************************************************************************* + ** Tag vocabulary and assignments. Parent authorization precedes trusted storage. + ** Mutations join the caller's backend transaction; the config lock serializes + ** service calls within this instance, as in the existing collaboration services. + *******************************************************************************/ +public class TagsService +{ + public static final String MANAGE = com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions.TAGS_MANAGE; + + /******************************************************************************* + ** Case-independent natural key; labels retain the user's capitalization. + *******************************************************************************/ + public static String normalize(String label) throws QException + { + String name = label == null ? "" : label.strip().toLowerCase(Locale.ROOT).replaceAll("(?U)\\s+", " ").strip(); + if(name.isEmpty() || name.length() > 60) + { + throw (new QException("Tag names must contain 1 to 60 characters.")); + } + return (name); + } + + /******************************************************************************* + ** Global and table vocabulary; pending suggestions are private to their author. + *******************************************************************************/ + public List vocabulary(String table, String q, Integer limit) throws QException + { + requireTable(table); + QQueryFilter filter = vocabularyFilter(table).withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR) + .withCriteria("approved", QCriteriaOperator.EQUALS, true) + .withCriteria("createdByUserId", QCriteriaOperator.EQUALS, PersonalRecords.currentUserId())); + if(q != null && !q.isBlank()) + { + filter.withCriteria("name", QCriteriaOperator.CONTAINS, q.strip().toLowerCase(Locale.ROOT)); + } + filter.withOrderBy(new QFilterOrderBy("usageCount", false)).withOrderBy(new QFilterOrderBy("label")).withLimit(limit == null ? 20 : Math.max(1, Math.min(100, limit))); + return (CollaborationRecords.query(CollaborationTables.TAG, filter).stream().map(TagsService::entry).toList()); + } + + /******************************************************************************* + ** Shared picker filter, also enforced when submitted values reach the service. + *******************************************************************************/ + public static QQueryFilter vocabularyFilter(String table) + { + QQueryFilter scope = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("tableName", QCriteriaOperator.IS_BLANK); + if(table != null) + { + scope.withCriteria("tableName", QCriteriaOperator.EQUALS, table); + } + return (new QQueryFilter().withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK).withSubFilter(scope)); + } + + /******************************************************************************* + ** Resolve persisted policy as system, preserving the caller and transaction. + ** The preferred app path supplies the APP scope when a table has several apps. + *******************************************************************************/ + public String mode(String table) throws QException + { + List resolved = new ArrayList<>(); + QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), new QSystemUserSession(), QContext.getQBackendTransaction(), null), () -> + { + String path = QContext.getQInstance().getTablePath(table); + String[] parts = path == null ? new String[0] : path.split("/"); + String app = parts.length > 2 ? parts[parts.length - 2] : null; + for(String scope : List.of("TABLE", "APP", "INSTANCE")) + { + if(scope.equals("APP") && app == null) + { + continue; + } + QQueryFilter filter = new QQueryFilter().withCriteria("feature", QCriteriaOperator.EQUALS, "TAGS") + .withCriteria("scopeType", QCriteriaOperator.EQUALS, scope); + if(!scope.equals("INSTANCE")) + { + filter.withCriteria("scopeName", QCriteriaOperator.EQUALS, scope.equals("TABLE") ? table : app); + } + for(QRecord row : CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, filter)) + { + String json = row.getValueString("settings"); + if(json != null && !json.isBlank()) + { + Object value = settingMode(json); + if(value != null) + { + String mode = value.toString(); + if(!List.of("FREE", "APPROVED_ONLY").contains(mode)) + { + throw (new QException("Invalid TAGS mode: " + mode)); + } + resolved.add(mode); + return; + } + } + } + } + }); + return (resolved.isEmpty() ? CollaborationRecords.config().getDefaultTagMode() : resolved.getFirst()); + } + + /******************************************************************************* + ** Invalid JSON fails closed instead of silently relaxing an approval policy. + *******************************************************************************/ + private Object settingMode(String json) throws QException + { + try + { + return (JsonUtils.toObject(json, Map.class).get("mode")); + } + catch(Exception e) + { + throw (new QException("Invalid TAGS settings JSON", e)); + } + } + + /******************************************************************************* + ** Create an approved free tag or a pending suggestion. Globals require manage. + *******************************************************************************/ + public TagEntry create(String table, String label, String color) throws QException + { + if(table == null) + { + requireManage(); + } + else + { + ParentRecordAccess.requireTableFeature(table, "tag"); + } + synchronized(CollaborationRecords.config()) + { + String name = normalize(label); + QQueryFilter filter = new QQueryFilter().withCriteria("name", QCriteriaOperator.EQUALS, name); + filter.withCriteria("tableName", table == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, table); + List existing = CollaborationRecords.query(CollaborationTables.TAG, filter); + if(!existing.isEmpty()) + { + QRecord row = existing.getFirst(); + if(row.getValue("mergedIntoTagId") != null || (!Boolean.TRUE.equals(row.getValueBoolean("approved")) + && !PersonalRecords.currentUserId().equals(row.getValueString("createdByUserId")) && !canManage())) + { + throw (new QException("Tag name is already reserved.")); + } + return (entry(row)); + } + boolean approved = table == null || canManage() || mode(table).equals("FREE"); + return (entry(CollaborationRecords.insert(CollaborationTables.TAG, new QRecord().withValue("name", name) + .withValue("label", label.strip()).withValue("color", color).withValue("tableName", table).withValue("approved", approved) + .withValue("createdByUserId", PersonalRecords.currentUserId()).withValue("usageCount", 0)))); + } + } + + /******************************************************************************* + ** Read chips only after resolving the canonical, visible parent key. + *******************************************************************************/ + public List tagsFor(String table, Serializable recordId) throws QException + { + String id = parentId(table, recordId); + List ids = assignments(table, id).stream().map(row -> row.getValueInteger("tagId")).toList(); + if(ids.isEmpty()) + { + return (List.of()); + } + return (CollaborationRecords.query(CollaborationTables.TAG, vocabularyFilter(table).withCriteria("id", QCriteriaOperator.IN, ids) + .withCriteria("approved", QCriteriaOperator.EQUALS, true).withOrderBy(new QFilterOrderBy("label"))).stream().map(TagsService::entry).toList()); + } + + /******************************************************************************* + ** Resolve and validate all inputs before changing assignments. Numbers are ids; + ** strings are labels (including numeric labels), with global matches preferred. + *******************************************************************************/ + List resolve(String table, Collection values, boolean createMissing) throws QException + { + Map result = new LinkedHashMap<>(); + for(Serializable value : values == null ? List.of() : values) + { + QQueryFilter filter = vocabularyFilter(table); + if(value instanceof Number) + { + filter.withCriteria("id", QCriteriaOperator.EQUALS, value); + } + else + { + filter.withCriteria("name", QCriteriaOperator.EQUALS, normalize(Objects.toString(value, null))); + } + List matches = CollaborationRecords.query(CollaborationTables.TAG, filter.withOrderBy(new QFilterOrderBy("id"))); + QRecord row; + if(matches.isEmpty()) + { + if(!createMissing || value instanceof Number || !mode(table).equals("FREE")) + { + throw (new QException("Pick an approved tag (or suggest one)")); + } + row = requireTag(create(table, value.toString(), null).id()); + } + else + { + row = matches.stream().filter(tag -> tag.getValue("tableName") == null).findFirst().orElse(matches.getFirst()); + } + if(!Boolean.TRUE.equals(row.getValueBoolean("approved"))) + { + throw (new QException("Pick an approved tag (or suggest one)")); + } + result.put(row.getValueInteger("id"), row); + } + return (new ArrayList<>(result.values())); + } + + /******************************************************************************* + ** Idempotent assignment changes, with activity only for actual changes. + ** Tags intentionally do not notify followers. + *******************************************************************************/ + public void apply(String table, Serializable recordId, Collection add, Collection remove) throws QException + { + ParentRecordAccess.requireFeature(table, recordId, "tag"); + String id = parentId(table, recordId); + synchronized(CollaborationRecords.config()) + { + List removals = resolve(table, remove, false); + List additions = resolve(table, add, true); + for(QRecord tag : removals) + { + List links = assignments(table, id).stream().filter(row -> Objects.equals(row.getValue("tagId"), tag.getValue("id"))).toList(); + for(QRecord link : links) + { + CollaborationRecords.delete(CollaborationTables.RECORD_TAG, link.getValue("id")); + ActivityWriter.write(table, id, "UNTAGGED", "Tag removed", CollaborationTables.TAG, tag.getValueString("id")); + } + recompute(tag.getValueInteger("id")); + } + for(QRecord tag : additions) + { + if(assignments(table, id).stream().noneMatch(row -> Objects.equals(row.getValue("tagId"), tag.getValue("id")))) + { + CollaborationRecords.insert(CollaborationTables.RECORD_TAG, new QRecord().withValue("tableName", table).withValue("recordId", id) + .withValue("tagId", tag.getValue("id")).withValue("createdByUserId", PersonalRecords.currentUserId()).withValue("parentDeleted", false)); + ActivityWriter.write(table, id, "TAGGED", "Tag added", CollaborationTables.TAG, tag.getValueString("id")); + } + recompute(tag.getValueInteger("id")); + } + } + } + + /******************************************************************************* + ** Approval never changes an already merged alias. + *******************************************************************************/ + public void approve(Serializable id) throws QException + { + requireManage(); + synchronized(CollaborationRecords.config()) + { + requireTag(id); + CollaborationRecords.update(CollaborationTables.TAG, new QRecord().withValue("id", id).withValue("approved", true)); + } + } + + /******************************************************************************* + ** Validate a merge before review and again before load; prevent scope loss. + *******************************************************************************/ + public QRecord mergeTarget(Serializable sourceId, Serializable targetId) throws QException + { + requireManage(); + QRecord source = requireTag(sourceId); + QRecord target = requireTag(targetId); + if(Objects.equals(source.getValue("id"), target.getValue("id")) || !Boolean.TRUE.equals(target.getValueBoolean("approved")) + || (target.getValue("tableName") != null && !Objects.equals(source.getValue("tableName"), target.getValue("tableName")))) + { + throw (new QException("Choose a different approved tag in the same scope or global vocabulary.")); + } + return (target); + } + + /******************************************************************************* + ** Move references with deduplication and retain the source as an audited alias. + *******************************************************************************/ + public void merge(Serializable sourceId, Serializable targetId) throws QException + { + requireManage(); + synchronized(CollaborationRecords.config()) + { + QRecord target = mergeTarget(sourceId, targetId); + for(QRecord link : links(sourceId)) + { + boolean duplicate = assignments(link.getValueString("tableName"), link.getValueString("recordId")).stream() + .anyMatch(row -> Objects.equals(row.getValue("tagId"), target.getValue("id"))); + if(duplicate) + { + CollaborationRecords.delete(CollaborationTables.RECORD_TAG, link.getValue("id")); + } + else + { + CollaborationRecords.update(CollaborationTables.RECORD_TAG, new QRecord().withValue("id", link.getValue("id")).withValue("tagId", target.getValue("id"))); + } + } + CollaborationRecords.update(CollaborationTables.TAG, new QRecord().withValue("id", sourceId).withValue("mergedIntoTagId", target.getValue("id"))); + recompute(sourceId); + recompute(targetId); + } + } + + /******************************************************************************* + ** Delete the vocabulary entry and its assignments through the managed action. + *******************************************************************************/ + public void delete(Serializable id) throws QException + { + requireManage(); + synchronized(CollaborationRecords.config()) + { + for(QRecord link : links(id)) + { + CollaborationRecords.delete(CollaborationTables.RECORD_TAG, link.getValue("id")); + } + CollaborationRecords.delete(CollaborationTables.TAG, id); + } + } + + /******************************************************************************* + ** Trusted parent cleanup callback; the parent no longer exists to authorize. + *******************************************************************************/ + public void onParentsDeleted(String table, List ids) throws QException + { + synchronized(CollaborationRecords.config()) + { + for(QRecord link : CollaborationRecords.query(CollaborationTables.RECORD_TAG, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table) + .withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()))) + { + CollaborationRecords.delete(CollaborationTables.RECORD_TAG, link.getValue("id")); + recompute(link.getValue("tagId")); + } + } + } + + /******************************************************************************* + ** Recount instead of incrementing a potentially stale denormalized value. + *******************************************************************************/ + private void recompute(Serializable id) throws QException + { + CollaborationRecords.update(CollaborationTables.TAG, new QRecord().withValue("id", id).withValue("usageCount", links(id).size())); + } + + /******************************************************************************* + ** Internal references used by management actions after checking manage. + *******************************************************************************/ + static List links(Serializable id) throws QException + { + return (CollaborationRecords.query(CollaborationTables.RECORD_TAG, new QQueryFilter().withCriteria("tagId", QCriteriaOperator.EQUALS, id))); + } + + /******************************************************************************* + ** Canonical assignment list for an authorized parent. + *******************************************************************************/ + private List assignments(String table, String id) throws QException + { + return (CollaborationRecords.query(CollaborationTables.RECORD_TAG, CollaborationRecords.parent(table, id))); + } + + /******************************************************************************* + ** Reject missing and merged vocabulary ids. + *******************************************************************************/ + static QRecord requireTag(Serializable id) throws QException + { + List rows = CollaborationRecords.query(CollaborationTables.TAG, new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id) + .withCriteria("mergedIntoTagId", QCriteriaOperator.IS_BLANK)); + if(rows.isEmpty()) + { + throw (new QNotFoundException("Tag not found")); + } + return (rows.getFirst()); + } + + /******************************************************************************* + ** Table reads guard vocabulary calls without needing a particular record. + *******************************************************************************/ + static void requireTable(String table) throws QException + { + if(!ParentRecordAccess.canReadTable(table)) + { + throw (new QNotFoundException("Not found")); + } + } + + /******************************************************************************* + ** Canonical keys prevent aliases such as 01 from creating duplicate assignments. + *******************************************************************************/ + private String parentId(String table, Serializable id) throws QException + { + return (ParentRecordAccess.requireVisibleParent(table, id).getValueString(QContext.getQInstance().getTable(table).getPrimaryKeyField())); + } + + /******************************************************************************* + ** Management is an explicit permission independent of collaboration.admin. + *******************************************************************************/ + static boolean canManage() + { + return (QContext.getQSession().hasPermission(MANAGE)); + } + + /******************************************************************************* + ** Shared action and service permission check. + *******************************************************************************/ + public static void requireManage() throws QPermissionDeniedException + { + if(!canManage()) + { + throw (new QPermissionDeniedException("This needs " + MANAGE + ".")); + } + } + + /******************************************************************************* + ** Public vocabulary projection. + *******************************************************************************/ + private static TagEntry entry(QRecord row) + { + return (new TagEntry(row.getValueInteger("id"), row.getValueString("name"), row.getValueString("label"), row.getValueString("color"), + row.getValueString("tableName"), row.getValueBoolean("approved"), row.getValueInteger("usageCount"))); + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java index 96f1cdf..682c019 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java @@ -114,7 +114,7 @@ class CollaborationTest CollaborationTables.SCOPE_TYPE, CollaborationTables.FEATURE, CollaborationTables.PERMISSION_MODE); assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12); assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName()) - .containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER); + .containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER); assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET); assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull(); assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD); @@ -177,9 +177,9 @@ class CollaborationTest QInstance headless = new QInstance(); headless.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); new CollaborationQBitProducer().withQBitConfig(config.withAdminApp(false)).produce(headless); - assertThat(headless.getTables()).hasSize(11); + assertThat(headless.getTables()).hasSize(13); assertThat(headless.getApps()).isEmpty(); - assertThat(headless.getWidgets()).hasSize(3); + assertThat(headless.getWidgets()).hasSize(4); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java index f13cb2e..6764abf 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/ConversationServicesTest.java @@ -489,11 +489,13 @@ public class ConversationServicesTest extends PersonalServiceFixture installed.addSecurityKeyType(instance.getSecurityKeyType("tenant")); installed.addScheduler(new com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData().withName("minute")); CollaborationQBitConfig configured = new CollaborationQBitConfig().withBackendName("memory").withFollowActionTables(List.of("document")) - .withFollowChangeTables(List.of("document")).withSchedulerName("minute"); + .withFollowChangeTables(List.of("document")).withTagActionTables(List.of("document")).withSchedulerName("minute"); new CollaborationQBitProducer().withQBitConfig(configured).produce(installed); assertThat(installed.getProcess("collaborationFollow_document").getLabel()).isEqualTo("Follow"); assertThat(installed.getProcess("collaborationUnfollow_document").getLabel()).isEqualTo("Unfollow"); assertThat(installed.getRecordChangeListeners()).hasSize(1); + assertThat(installed.getProcess("collaborationAddTags_document").getLabel()).isEqualTo("Add tags"); + assertThat(installed.getProcess("collaborationRemoveTags_document").getLabel()).isEqualTo("Remove tags"); assertThat(installed.getProcess(SendFollowDigestsProcess.NAME).getSchedule().getRepeatSeconds()).isEqualTo(60); assertThat(installed.getProcess(SendFollowDigestsProcess.NAME).getIsHidden()).isTrue(); String sql = CollaborationSchema.postgresScript(config); diff --git a/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java new file mode 100644 index 0000000..1209057 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/TagsServiceTest.java @@ -0,0 +1,448 @@ +/* + * 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.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.collaboration.app.CollaborationHome; +import com.kingsrook.qbits.collaboration.attachments.AttachmentCleanup; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.tags.TagEntry; +import com.kingsrook.qbits.collaboration.tags.TagFilterTranslator; +import com.kingsrook.qbits.collaboration.tags.TagProcesses; +import com.kingsrook.qbits.collaboration.tags.TagsService; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +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.QNotFoundException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +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.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; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Real QQQ actions exercise tag permissions, persistence, processes and metadata. + *******************************************************************************/ +public class TagsServiceTest extends PersonalServiceFixture +{ + private final TagsService tags = new TagsService(); + private final TagFilterTranslator filters = new TagFilterTranslator(); + + /******************************************************************************* + ** Natural keys are locale-independent and validate after collapsing whitespace. + *******************************************************************************/ + @Test + void normalizationAndFreeCreate() throws Exception + { + assertThat(TagsService.normalize(" Priority:HIGH today ")).isEqualTo("priority:high today"); + assertThat(TagsService.normalize("a".repeat(60))).hasSize(60); + assertThatThrownBy(() -> TagsService.normalize("a".repeat(61))).hasMessageContaining("60"); + assertThatThrownBy(() -> TagsService.normalize(null)).hasMessageContaining("60"); + assertThatThrownBy(() -> TagsService.normalize(" ")).hasMessageContaining("60"); + TagEntry tag = tags.create("document", "Urgent", "red"); + assertThat(tag.name()).isEqualTo("urgent"); + assertThat(tag.label()).isEqualTo("Urgent"); + assertThat(tag.color()).isEqualTo("red"); + assertThat(tag.tableName()).isEqualTo("document"); + assertThat(tag.approved()).isTrue(); + assertThat(tag.usageCount()).isZero(); + assertThat(tags.create("document", " urgent ", null).id()).isEqualTo(tag.id()); + } + + /******************************************************************************* + ** Approval-only never permits applying a pending tag, including its author. + *******************************************************************************/ + @Test + void suggestionsAndUnknownLabels() throws Exception + { + config.withDefaultTagMode("APPROVED_ONLY"); + TagEntry pending = tags.create("document", "Suggested", null); + assertThat(pending.approved()).isFalse(); + assertThatThrownBy(() -> tags.apply("document", 1, List.of(pending.id()), List.of())).hasMessageContaining("Pick an approved tag"); + assertThatThrownBy(() -> tags.apply("document", 1, List.of("Unknown"), List.of())).hasMessageContaining("Pick an approved tag"); + assertThat(rows(CollaborationTables.TAG)).hasSize(1); + login("bob"); + assertThat(tags.vocabulary("document", null, 20)).isEmpty(); + assertThatThrownBy(() -> tags.create("document", "Suggested", null)).hasMessageContaining("reserved"); + login("alice", TagsService.MANAGE); + assertThat(tags.create("document", "Managed", null).approved()).isTrue(); + tags.approve(pending.id()); + tags.apply("document", 1, List.of(pending.id()), List.of()); + assertThat(tags.tagsFor("document", 1)).extracting(TagEntry::id).containsExactly(pending.id()); + config.withDefaultTagMode("FREE"); + tags.apply("document", 2, List.of("New on the fly"), List.of()); + assertThat(tags.tagsFor("document", 2)).extracting(TagEntry::name).containsExactly("new on the fly"); + } + + /******************************************************************************* + ** Global vocabulary and management demand the explicit permission. + *******************************************************************************/ + @Test + void globalsAndManagementPermissions() throws Exception + { + assertThatThrownBy(() -> tags.create(null, "Global", null)).isInstanceOf(QPermissionDeniedException.class); + TagEntry own = tags.create("document", "Local", null); + assertThatThrownBy(() -> tags.approve(own.id())).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> tags.delete(own.id())).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> tags.merge(own.id(), own.id())).isInstanceOf(QPermissionDeniedException.class); + login("manager", TagsService.MANAGE); + TagEntry global = tags.create(null, "Global", null); + assertThat(global.tableName()).isNull(); + assertThat(global.approved()).isTrue(); + assertThat(tags.vocabulary("document", "glo", null)).extracting(TagEntry::id).containsExactly(global.id()); + } + + /******************************************************************************* + ** Invisible parents return Not found; read-only users need an explicit grant. + *******************************************************************************/ + @Test + void parentAndFeatureSecurity() throws Exception + { + TagEntry tag = tags.create("document", "A", null); + assertThatThrownBy(() -> tags.tagsFor("document", 3)).isInstanceOf(QNotFoundException.class).hasMessage("Not found"); + assertThatThrownBy(() -> tags.apply("document", 3, List.of(tag.id()), List.of())).isInstanceOf(QNotFoundException.class); + QContext.init(instance, new QSession().withUser(new QUser().withIdReference("reader")).withPermissions("document.read").withSecurityKeyValue("tenant", "a")); + assertThatThrownBy(() -> tags.create("document", "No", null)).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> tags.apply("document", 1, List.of(tag.id()), List.of())).isInstanceOf(QPermissionDeniedException.class); + QContext.getQSession().withPermissions("document.tag"); + tags.create("document", "Granted", null); + tags.apply("document", 1, List.of(tag.id()), List.of()); + assertThat(tags.tagsFor("document", 1)).hasSize(1); + assertThatThrownBy(() -> tags.vocabulary("missing", null, 1)).isInstanceOf(QNotFoundException.class); + assertThatThrownBy(() -> tags.create("missing", "No", null)).isInstanceOf(QNotFoundException.class); + } + + /******************************************************************************* + ** Repeated add/remove does not inflate counts or produce duplicate activity. + *******************************************************************************/ + @Test + void idempotentAssignmentsCountsAndActivity() throws Exception + { + TagEntry tag = tags.create("document", "A", null); + tags.apply("document", "01", List.of(tag.id(), tag.id()), List.of()); + tags.apply("document", 1, List.of("A"), null); + tags.apply("document", 2, List.of(tag.id()), List.of()); + assertThat(rows(CollaborationTables.RECORD_TAG)).hasSize(2); + assertThat(tags.vocabulary("document", null, 20).getFirst().usageCount()).isEqualTo(2); + assertThat(rows(CollaborationTables.ACTIVITY)).extracting(row -> row.getValueString("type")).containsExactly("TAGGED", "TAGGED"); + assertThat(rows(CollaborationTables.FOLLOW_DIGEST)).isEmpty(); + tags.apply("document", 1, null, List.of(tag.id())); + tags.apply("document", 1, List.of(), List.of("A")); + assertThat(tags.tagsFor("document", 1)).isEmpty(); + assertThat(tags.vocabulary("document", null, 20).getFirst().usageCount()).isEqualTo(1); + assertThat(rows(CollaborationTables.ACTIVITY)).hasSize(3); + } + + /******************************************************************************* + ** TABLE wins over APP then INSTANCE then the code default, with caller restored. + *******************************************************************************/ + @Test + void settingsHierarchyReadAsSystem() throws Exception + { + instance.addApp(new QAppMetaData().withName("work").withLabel("Work").withChild(instance.getTable("document")).withSection(new QAppSection().withName("main").withTables(List.of("document")))); + assertThat(tags.mode("document")).isEqualTo("FREE"); + setting("INSTANCE", "*", "APPROVED_ONLY"); + assertThat(tags.mode("document")).isEqualTo("APPROVED_ONLY"); + setting("APP", "work", "FREE"); + assertThat(tags.mode("document")).isEqualTo("FREE"); + setting("TABLE", "document", "APPROVED_ONLY"); + assertThat(tags.create("document", "Pending", null).approved()).isFalse(); + assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo("alice"); + assertThat(QContext.getQSession().hasPermission("collaboration.admin")).isFalse(); + } + + /******************************************************************************* + ** Vocabulary ordering, scope, limits and private suggestions are stable. + *******************************************************************************/ + @Test + void vocabularyOrderingScopeAndSuggestions() throws Exception + { + TagEntry z = tags.create("document", "Zulu", null); + TagEntry b = tags.create("document", "Beta", null); + TagEntry a = tags.create("document", "Alpha", null); + tags.apply("document", 1, List.of(z.id()), List.of()); + config.withDefaultTagMode("APPROVED_ONLY"); + TagEntry pending = tags.create("document", "Mine", null); + login("bob"); + tags.create("document", "Other", null); + login("alice"); + assertThat(tags.vocabulary("document", null, 1000)).extracting(TagEntry::id).containsExactly(z.id(), a.id(), b.id(), pending.id()); + assertThat(tags.vocabulary("document", " ALP ", 10)).extracting(TagEntry::id).containsExactly(a.id()); + assertThat(tags.vocabulary("document", null, 0)).hasSize(1); + assertThat(tags.vocabulary("document", null, 2)).hasSize(2); + } + + /******************************************************************************* + ** Merge deduplicates assignments, preserves aliases and rejects invalid scope. + *******************************************************************************/ + @Test + void mergeDeleteAndParentCleanup() throws Exception + { + TagEntry source = tags.create("document", "Source", null); + TagEntry target = tags.create("document", "Target", null); + tags.apply("document", 1, List.of(source.id(), target.id()), List.of()); + tags.apply("document", 2, List.of(source.id()), List.of()); + login("manager", TagsService.MANAGE); + assertThatThrownBy(() -> tags.merge(source.id(), source.id())).hasMessageContaining("different"); + TagEntry global = tags.create(null, "Global", null); + assertThatThrownBy(() -> tags.merge(global.id(), target.id())).hasMessageContaining("scope"); + tags.merge(source.id(), target.id()); + assertThat(tags.tagsFor("document", 1)).extracting(TagEntry::id).containsExactly(target.id()); + assertThat(tags.tagsFor("document", 2)).extracting(TagEntry::id).containsExactly(target.id()); + assertThat(tags.vocabulary("document", null, 20)).extracting(TagEntry::id).doesNotContain(source.id()); + assertThat(rows(CollaborationTables.TAG)).filteredOn(row -> row.getValueInteger("id").equals(source.id())) + .singleElement().satisfies(row -> { assertThat(row.getValueInteger("usageCount")).isZero(); assertThat(row.getValueInteger("mergedIntoTagId")).isEqualTo(target.id()); }); + assertThatThrownBy(() -> tags.create("document", "Source", null)).hasMessageContaining("reserved"); + new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1))); + new AttachmentCleanup().onParentsDeleted("document", List.of(1)); + new AttachmentCleanup().onParentsDeleted("document", List.of(1)); + assertThat(tags.vocabulary("document", null, 20).getFirst().usageCount()).isEqualTo(1); + tags.delete(target.id()); + assertThat(rows(CollaborationTables.RECORD_TAG)).isEmpty(); + } + + /******************************************************************************* + ** All translator operators use visible parent ids, with set semantics. + *******************************************************************************/ + @Test + void translatorOperators() throws Exception + { + TagEntry a = tags.create("document", "A", null); + TagEntry b = tags.create("document", "B", null); + tags.apply("document", 1, List.of(a.id(), b.id()), List.of()); + tags.apply("document", 2, List.of(a.id()), List.of()); + assertThat(filters.recordIds("document", "IN", List.of("A"))).containsExactly("1", "2"); + assertThat(filters.recordIds("document", "IN", List.of(b.id()))).containsExactly("1"); + assertThat(filters.recordIds("document", "CONTAINS_ALL", List.of(a.id(), b.id(), a.id()))).containsExactly("1"); + assertThat(filters.recordIds("document", "CONTAINS_ALL", List.of(a.id(), "unknown"))).isEmpty(); + assertThat(filters.recordIds("document", "NOT_IN", List.of(b.id()))).containsExactly("2"); + assertThat(filters.recordIds("document", "IS_NOT_BLANK", null)).containsExactly("1", "2"); + assertThat(filters.recordIds("document", "IS_BLANK", null)).isEmpty(); + tags.apply("document", 2, List.of(), List.of(a.id())); + assertThat(filters.recordIds("document", "IS_BLANK", List.of())).containsExactly("2"); + assertThat(filters.recordIds("document", "IN", List.of())).isEmpty(); + assertThat(filters.recordIds("document", "NOT_IN", List.of("unknown"))).containsExactly("1", "2"); + assertThat(filters.recordIds("document", "CONTAINS_ALL", List.of())).containsExactly("1", "2"); + assertThatThrownBy(() -> filters.recordIds("document", "LIKE", List.of())).hasMessageContaining("Unsupported"); + } + + /******************************************************************************* + ** Exactly 10,000 is allowed; the next matching id fails instead of truncating. + *******************************************************************************/ + @Test + void translatorCap() throws Exception + { + List parents = new ArrayList<>(); + for(int id = 4; id <= 10001; id++) + { + parents.add(new QRecord().withValue("id", id).withValue("tenant", "a")); + } + new InsertAction().execute(new InsertInput("document").withRecords(parents)); + assertThat(filters.recordIds("document", "IS_BLANK", null)).hasSize(10000); + new InsertAction().execute(new InsertInput("document").withRecords(List.of(new QRecord().withValue("id", 10002).withValue("tenant", "a")))); + assertThatThrownBy(() -> filters.recordIds("document", "IS_BLANK", null)).hasMessageContaining("10,000"); + } + + /******************************************************************************* + ** Direct access cannot bypass vocabulary approval or parent lock restrictions. + *******************************************************************************/ + @Test + void tableSecurityAndHome() throws Exception + { + TagEntry tag = tags.create("document", "", null); + tags.apply("document", 1, List.of(tag.id()), List.of()); + config.withDefaultTagMode("APPROVED_ONLY"); + TagEntry pending = tags.create("document", "Pending", null); + login("bob", "collaboration.view"); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.TAG)).getRecords()).hasSize(1); + assertThat(new CountAction().execute(new CountInput(CollaborationTables.TAG)).getCount()).isEqualTo(1); + assertThatThrownBy(() -> new GetAction().execute(new GetInput(CollaborationTables.TAG).withPrimaryKey(pending.id()))).isInstanceOf(QPermissionDeniedException.class); + assertThat(new GetAction().execute(new GetInput(CollaborationTables.TAG).withPrimaryKey(tag.id())).getRecord()).isNotNull(); + assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(CollaborationTables.TAG).withRecords(List.of(new QRecord())))).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.TAG).withRecords(List.of(new QRecord().withValue("id", tag.id()))))).isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(CollaborationTables.TAG).withPrimaryKeys(List.of(tag.id())))).isInstanceOf(QPermissionDeniedException.class); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_TAG)).getRecords()).hasSize(1); + QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b")); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECORD_TAG)).getRecords()).isEmpty(); + login("manager", "collaboration.admin", TagsService.MANAGE); + String html = CollaborationHome.render(Instant.now()); + assertThat(html).contains("Vocabulary size: 2", "Pending suggestions: 1", "<Urgent>: 1"); + assertThat(html).doesNotContain(""); + } + + /******************************************************************************* + ** Metadata, nullable uniqueness and scoped PVS are validated together. + *******************************************************************************/ + @Test + void schemaAndMetadata() throws Exception + { + TagProcesses.processesFor("document").forEach(instance::addProcess); + new QInstanceValidator().validate(instance); + assertThat(instance.getTable(CollaborationTables.TAG).getSearchFields()).containsExactly("label"); + assertThat(instance.getTable(CollaborationTables.TAG).getSections()).anySatisfy(section -> assertThat(section.getLabel()).isEqualTo("Record Tags")); + assertThat(instance.getPossibleValueSource(CollaborationTables.TAG).getTableName()).isEqualTo(CollaborationTables.TAG); + QFrontendStepMetaData pick = (QFrontendStepMetaData) instance.getProcess("collaborationAddTags_document").getStep("tags"); + assertThat(pick.getFormFields().getFirst().getPossibleValueSourceName()).isEqualTo(CollaborationTables.TAG); + assertThat(pick.getComponents().getFirst().getValues().get("multiSelectFields")).isEqualTo(List.of("tags")); + assertThat(CollaborationSchema.postgresScript(config)).contains("ON collaboration_tag (table_name, name) NULLS NOT DISTINCT", "ON collaboration_record_tag (table_name, record_id, tag_id)", "usage_count INTEGER NOT NULL", "approved BOOLEAN NOT NULL"); + assertThatThrownBy(() -> config.withDefaultTagMode("INVALID").validate(instance)).hasMessageContaining("defaultTagMode"); + config.withDefaultTagMode("FREE").withTagActionTables(List.of("missing")); + assertThatThrownBy(() -> config.validate(instance)).hasMessageContaining("host table"); + } + + /******************************************************************************* + ** Bulk actions execute genuine Review and Result steps with idempotent counts. + *******************************************************************************/ + @Test + void bulkActionsReviewAndResult() throws Exception + { + TagProcesses.processesFor("document").forEach(instance::addProcess); + TagEntry a = tags.create("document", "A", null); + TagEntry b = tags.create("document", "B", null); + tags.apply("document", 1, List.of(a.id(), b.id()), List.of()); + RunProcessOutput review = review("collaborationAddTags_document", "1,2", Map.of(TagProcesses.TAGS, new ArrayList<>(List.of(a.id(), b.id())))); + assertThat(lines(review, false)).anySatisfy(line -> { assertThat(line.getCount()).isEqualTo(2); assertThat(line.getPluralFutureMessage()).contains("Add 2 tags"); }); + assertThat(lines(review, false)).anySatisfy(line -> { assertThat(line.getCount()).isEqualTo(1); assertThat(line.getPluralFutureMessage()).contains("already"); }); + assertThat(tags.tagsFor("document", 2)).isEmpty(); + assertThat(lines(finish(review), true)).anySatisfy(line -> assertThat(line.getCount()).isEqualTo(2)); + assertThat(tags.tagsFor("document", 2)).hasSize(2); + finish(review("collaborationRemoveTags_document", "1,2", Map.of(TagProcesses.TAGS, a.id() + "," + b.id()))); + assertThat(rows(CollaborationTables.RECORD_TAG)).isEmpty(); + } + + /******************************************************************************* + ** Management Review counts affected records; Result performs each operation. + *******************************************************************************/ + @Test + void managementActionsReviewAndResult() throws Exception + { + config.withDefaultTagMode("APPROVED_ONLY"); + TagEntry pending = tags.create("document", "Pending", null); + login("manager", TagsService.MANAGE); + RunProcessOutput approve = review("collaborationApproveTags", pending.id().toString(), Map.of()); + assertThat(lines(approve, false)).anySatisfy(line -> assertThat(line.getPluralFutureMessage()).contains("Approve")); + finish(approve); + tags.apply("document", 1, List.of(pending.id()), List.of()); + TagEntry target = tags.create("document", "Target", null); + RunProcessOutput merge = review("collaborationMergeTags", pending.id().toString(), Map.of(TagProcesses.TARGET, target.id())); + finish(merge); + assertThat(tags.tagsFor("document", 1)).extracting(TagEntry::id).containsExactly(target.id()); + RunProcessOutput delete = review("collaborationDeleteTags", target.id().toString(), Map.of()); + assertThat(lines(delete, false)).anySatisfy(line -> { assertThat(line.getCount()).isEqualTo(1); assertThat(line.getPluralFutureMessage()).contains("lose tag assignments"); }); + assertThat(lines(finish(delete), true)).anySatisfy(line -> assertThat(line.getPluralPastMessage()).contains("processed")); + assertThat(tags.tagsFor("document", 1)).isEmpty(); + } + + /******************************************************************************* + ** Persist one scoped feature policy as a trusted fixture setup. + *******************************************************************************/ + private void setting(String scope, String name, String mode) throws Exception + { + CollaborationRecords.insert(CollaborationTables.FEATURE_SETTING, new QRecord().withValue("scopeType", scope).withValue("scopeName", name) + .withValue("feature", "TAGS").withValue("enabled", true).withValue("settings", "{\"mode\":\"" + mode + "\"}")); + } + + /******************************************************************************* + ** Inspect persistence to assert no hidden writes or duplicate activity. + *******************************************************************************/ + private List rows(String table) throws Exception + { + return (CollaborationRecords.query(table, new QQueryFilter())); + } + + /******************************************************************************* + ** Supply picker values and stop at the genuine standard Review screen. + *******************************************************************************/ + private RunProcessOutput review(String name, String records, Map values) throws Exception + { + new QInstanceValidator().validate(instance); + RunProcessInput input = new RunProcessInput(); + input.setProcessName(name); + input.addValue("testProcessName", name); + input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK); + input.addValue(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + input.addValue("recordsParam", "recordIds"); + input.addValue("recordIds", records); + values.forEach(input::addValue); + if(instance.getProcess(name).getStep("tags") != null) + { + RunProcessOutput picker = new RunProcessAction().execute(input); + assertThat(picker.getException()).isEmpty(); + input.setProcessUUID(picker.getProcessUUID()); + input.setStartAfterStep("tags"); + } + RunProcessOutput output = new RunProcessAction().execute(input); + assertThat(output.getException()).isEmpty(); + return (output); + } + + /******************************************************************************* + ** Resume the stored process state after Review. + *******************************************************************************/ + private RunProcessOutput finish(RunProcessOutput review) throws Exception + { + RunProcessInput go = new RunProcessInput(); + go.setProcessName((String) review.getValues().get("testProcessName")); + go.setProcessUUID(review.getProcessUUID()); + go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW); + go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + RunProcessOutput result = new RunProcessAction().execute(go); + assertThat(result.getException()).isEmpty(); + return (result); + } + + /******************************************************************************* + ** Summary values are supplied by the streamed process engine. + *******************************************************************************/ + @SuppressWarnings("unchecked") + private List lines(RunProcessOutput output, boolean result) + { + return ((List) output.getValues().get(result ? StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY : StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY)); + } +} -- 2.54.0