# Groups Module Enterprise social groups: creation, membership, posts, reactions, comments, notifications, reporting, multi-company support. ## Directory Structure ``` groups/ ├── business/ │ ├── constants.ts # Group policies, member roles, cache keys │ ├── sort.ts # Sorting options for group lists │ ├── interfaces/ # 30+ TypeScript interfaces for input/output contracts │ ├── models/ # 15 domain models (Group, GroupMember, GroupPost, etc.) │ ├── ports/ # 16 abstract ports (dependency inversion) │ └── services/ # 15 service classes (see below) ├── infrastructure/ │ ├── adapters/ # 15 concrete port implementations │ ├── dataAccessObjects/ # 13 Sequelize DAOs │ └── mappers/ # 15 pure mapper functions (DAO ↔ Domain) ├── presentation/ │ ├── controllers/groupsController.ts # HTTP endpoints orchestrating all services │ ├── routers/ # 3 router functions (App, Backoffice, Devops) │ ├── dataTransferObjects/ # 17 response DTOs │ ├── serializationClasses/ # 26 serialization classes │ └── validationClasses/ # 33 validation classes └── groupsPortsDI.ts # Port → Adapter DI registration ``` ## Service Map — Which Service Owns Each Feature When implementing a new feature, use this table to find the right service: | Feature Area | Service | |---|---| | Group CRUD (create, edit, delete, archive) | `GroupsService` | | Group discovery / listing / exploring | `BaseGroupService` | | Find group by id (shared by all services) | `BaseGroupService` | | Gathering creation for a group instance | `GroupsGatheringService` | | List / count instances linked to a group | `GroupsGatheringService` | | Multi-company invitations and instance linking | `MulticompanyGroupsService` | | Group member add/remove/role changes | `GroupMembersService` | | Membership policy templates (segmentation) | `GroupMembersService` | | Join requests (create/accept/reject) | `GroupRequestsService` | | Group posts (create/edit/delete/list) | `GroupPostService` | | Post comments (create/edit/delete/list) | `GroupPostService` | | Post reactions (create/delete/list) | `GroupPostService` | | Post polls (vote, list answers) | `GroupPostService` | | Post approval / edit requests | `GroupPostService` | | Post scheduling and publishing | `GroupPostService` | | Livestream posts handling | `GroupPostService` | | Pinning / unpinning posts | `GroupPostService` | | Unread counts (red bubbles) | `GroupRedBubbleService` | | Pinned groups (reorder) | `GroupPinnedGroupService` | | Reports (members, bulk import) | `GroupReportService` | | Report progress locking (cache) | `GroupReportService` | | Group statistics (most active members, summary) | `GroupStatsService` | | CDC Kafka consumer (users and groups topics) | `GroupsCDCService` | | Shared guards (isEditable, canPost, etc.) | `GroupsUtilService` | | Email / push / NC notifications | `GroupsNotificationService` | | Group post search document (build + create/update/delete) | `GroupPostSearchService` | | HATEOAS edit links for posts | `GroupsLinkService` | --- ## Services — Detailed Reference ### `BaseGroupService` Shared read operations injected by all services that need to look up or list groups. Injects only `GroupsRepositoryPort` — no circular dependencies possible. - `findGroupById()` — Single group fetch with optional membership validation; throws `NotFoundError` if not found or user not a member when `failOnUserNotMember: true` - `findGroupsByIds()` — Batch fetch by IDs - `findGroupsMapByIds()` — Batch fetch returning `Map` for O(1) lookups - `adminListGroups()` — Paginated list for backoffice (no member filter) - `appListGroups()` — Paginated list for app users (member-filtered) - `exploreGroups()` — Group discovery with optional privacy/query filters --- ### `GroupsService` Core group lifecycle management. Owns creation, editing, deletion, archiving, and utilities. Delegates all read/list operations to `BaseGroupService`. **Group CRUD** - `createGroup()` — Creates group with initial members, gathering setup, and banner validation - `editGroup()` — Full replacement of group properties (title, description, policies, icon, banner) - `editGroupPartially()` — Selective field update; null params preserve existing values - `toggleArchiveGroup()` — Archives/unarchives group; cascades deletion of scheduled posts and drafts - `deleteGroup()` — Deletes group after validating user belongs to the same instance - `duplicateGroup()` — Internal method (used by DuplicationService) to copy a group to a new gathering **Discovery & Listing (delegated to `BaseGroupService`)** - `appListGroups()`, `adminListGroups()`, `exploreGroups()`, `findGroupById()`, `findGroupsByIds()`, `findGroupsMapByIds()` — thin delegates - `syncMembersCountOnInstance()` — Refreshes member count cache for all groups in instance **User/Auth Utilities** - `upsertGroupUsers()` — Bulk creates or updates group user records - `signGroupsImages()` — Signs image URLs for secure S3 access - `getGroupIdsOfUserId()` — Returns group IDs a user belongs to (optionally excluding archived) - `addArchivedMetadata()` — Enriches group with archived metadata if archived --- ### `GroupsGatheringService` Owns gathering creation for group instances and exposes listing/counting of linked instances. Injects `BaseGroupService` for group membership validation. - `createGatheringForGroup()` — Creates a `UserGathering` and links it to the group instance via `GroupInstanceGathering`; also updates the group's `userGatheringId` for the primary instance - `listGroupInstances()` — Validates user membership, then returns paginated instances with metadata - `countGroupInstancesByGroupId()` — Validates user membership, then returns instance count --- ### `MulticompanyGroupsService` Manages multi-company group invitations and instance linking. Injects `BaseGroupService` (for `findGroupsMapByIds`) and `GroupsGatheringService` (for `createGatheringForGroup`). - `linkInstanceToMultiCompanyGroup()` — Links a new instance to a multi-company group; creates gathering and initial member - `createMulticompanyGroupInvitation()` — Creates 24-hour expiring cross-instance invitation - `listMulticompanyInvitations()` — Paginated list of sent invitations for instance - `listReceivedMulticompanyInvitations()` — Paginated list of received invitations - `updateMulticompanyGroupInvitationStatus()` — Accepts/rejects invitation; auto-links instance on acceptance - `getMulticompanyInvitation()` — Fetches invitation for responder with expiration validation --- ### `GroupMembersService` All membership operations: adding/removing members, role management, member policy templates (segmentation-based dynamic membership), and processing gathering criteria changes from queue messages. **Membership CRUD** - `addMembersToGroup()` — Bulk adds users as members with roles; updates user gathering criteria - `addMembersToGroupByGroupMemberModels()` — Adds members from pre-built `GroupMember` models - `joinGroup()` — Adds single user as member with specified role - `joinGroupFromJob()` — Job queue wrapper around `joinGroup()` - `deleteGroupMembers()` — Removes members; validates they're individual-sourced; updates gathering criteria - `performGroupMembersDeletion()` — Internal: deletes in transaction; clears caches and last-time records - `bulkDeleteMembersFromGroup()` — Bulk deletes multiple members with source validation - `validateMembersCanBeDeleted()` — Ensures members marked for deletion have INDIVIDUAL source (not segmentation) - `handleGroupMembersChange()` — Updates group's member count after membership changes **Listing & Lookup** - `listGroupMembers()` — Paginated member list with instance name enrichment - `listAssignableGroupUsers()` — Lists instance users eligible for group assignment - `listGroupMemberIds()` — Returns all member IDs for a group - `areMembersOfGroup()` — Checks if given user IDs are all members - `countGroupMembers()` — Total member count (optionally excluding specific user IDs) - `findGroupMemberById()` — Fetches single member or throws `NotFoundError` - `findGroupMemberRoleById()` — Returns member role or `null` if not found - `updateGroupMemberRole()` — Changes member role (ADMIN/REGULAR) in transaction - `findGroupsInCommon()` — Finds groups shared between two users **Member Policy Templates (Dynamic Segmentation)** - `createMemberPolicyTemplate()` — Creates a policy template gathering for applying bulk criteria - `replicateCriteria()` — Copies existing group criteria to a new template - `getMemberPolicyTemplate()` — Fetches template by ID - `getMemberPolicy()` — Retrieves policy criteria (paginated) - `updateMemberPolicy()` — Replaces all criteria for gathering; returns user→criterion mapping - `updateGroupMembersWithCriteria()` — Applies policy criteria to group members and deletes template - `resyncGroupMembersAudience()` — Re-applies a group's existing gathering criteria to re-sync `GroupMembers` (devops-only path; uses `group.instanceId`, does not promote any user to admin) - `deleteMemberPolicyTemplates()` — Deletes template and its gathering in transaction - `prepareMembersPolicyExecution()` — Prepares policy; returns async function for actual application - `getMemberPolicyUsers()` — Fetches paginated users for a given criterion - `getMemberPolicyIndividualUsers()` — Fetches paginated users for individual-users-only criterion **Queue Event Handlers** - `handleUserGatheringCriteriaChange()` — Queue handler: updates group membership when user criteria change (add/remove) - `handleDlqUserGatheringCriteriaChange()` — DLQ handler: reconciles membership vs user gathering criteria --- ### `GroupPostService` All post-related operations: creation, editing, deletion, listing, comments, reactions, polls, approval workflows, scheduling, pinning, post statistics, and livestream posts. **Post CRUD** - `createGroupPost()` — Creates post with approval/publication state logic based on group policies - `createGroupPostByGroupPostForCreation()` — Creates from pre-built `GroupPostForCreation` model - `editGroupPost()` — Full post content update in transaction - `editGroupPostPartially()` — Selective post update - `editGroupPostPublicationDateAndSendNotificationFields()` — Reschedules post datetime; sends notifications - `deleteGroupPost()` — Deletes post and unsets from pinned groups - `deleteGroupPostsByUserId()` — Cascades deletion of all posts by a user in a group - `deleteGroupPostsByUserIdInChunks()` — Same as above but in configurable batches - `deleteScheduledAndDraftsForGroup()` — Cleanup: deletes all scheduled+draft posts for a group **Post Listing & Fetching** - `cursorPaginationListGroupPosts()` — Cursor-based paginated list (supports drafts, scheduled, published) - `cursorPaginationListUserGroupPosts()` — Cursor-based list of posts by a specific user - `pageLimitListGroupPosts()` — Limit/offset paginated list - `pageLimitListGroupPostRequests()` — Limit/offset list of edit requests - `findGroupPostById()` — Fetches single post by group ID + post ID - `listNewGroupPostsByPostIds()` — Fetches posts with view counts and reaction counts - `getGroupPostTranslation()` — Fetches translated post content if available **Post Interactions** - `createGroupPostComment()` — Creates comment on a post - `editGroupPostComment()` — Updates comment content - `listGroupPostComments()` — Paginated comment list - `findGroupPostCommentById()` — Fetches comment with optional children/attachments - `deleteGroupPostComment()` — Deletes comment - `createGroupPostReaction()` — Adds emoji reaction to post - `createGroupPostCommentReaction()` — Adds emoji reaction to comment - `deleteGroupPostReaction()` — Removes user's emoji reaction from post - `deleteGroupPostCommentReaction()` — Removes user's emoji reaction from comment - `listUsersOfGroupPostReaction()` — Lists users who reacted to a post with a given emoji - `listUsersOfGroupPostCommentReaction()` — Lists users who reacted to a comment - `createGroupPostPollAnswer()` — Records user's answer to a poll **Post Approval / Edit Requests** - `createGroupPostRequest()` — Creates edit review request for a post - `createGroupPostEditRequest()` — Creates edit request and returns notification params - `findGroupPostRequestById()` — Fetches edit request or throws error - `reviewGroupPosts()` — Bulk approves/rejects pending posts - `addContentToGroupPostRequest()` — Enriches edit requests with post content **Scheduling & Publishing** - `publishGroupPosts()` — Publishes scheduled posts at their publication datetime **Pinning** - `pinGroupPost()` — Pins a post to the group feed - `unpinGroupPost()` — Unpins a post from the group feed **Post Statistics & Views** - `setGroupPostAsViewed()` — Records post view by user - `getViewedByPostId()` — Returns viewed status map for a set of post IDs - `getGroupPostSummary()` — Calculates post metrics (views, reactions, comments) - `getGroupPostStatsViewers()` — Paginated list of post viewers with filter options - `signUrlsOnGroupPosts()` — Signs image URLs in post attachments **Helpers** - `addPostToGroupPosts()` — Enriches group posts with post content from posts module - `checkTaggedUserIds()` — Validates tagged user IDs belong to the group - `validateGroupPostCreation()` — Validates publication policy, approval rules before creation **Livestream Post Handlers** (via EventEmitter) - `handleLiveStreamEnded()` — Handles livestream end event; sends real-time socket update - `handleLiveStreamCaptionsUpdated()` — Updates post when captions are updated - `handleLiveStreamHLSStarted()` — Notifies when HLS recording starts - `handleLiveStreamRecorded()` — Saves recording as attachment and notifies members --- ### `GroupRedBubbleService` Implements `RedBubblesService`. Manages unread post counts for groups using Redis cache with TTL. Counts are cached and invalidated when users read posts or when new posts arrive. - `getUnreadCountOfId()` — Returns unread count for a specific group (from cache or calculated fresh) - `getUnreadCount()` — Total unread count across all groups for a user - `clearUnreadCountOfId()` — Marks group as read: updates last-time timestamp and clears caches - `clearGroupUnreadCache()` — Invalidates unread count cache for a specific group - `clearGroupUnreadIfNewPost()` — Conditionally clears cache if post is newer than user's last access - `addUnreadCountOnGroups()` — Bulk enriches group objects with their unread counts - `clearCacheOfGroupUsersRedBubble()` — Bulk invalidates group unread cache for multiple users - `clearCacheMainRedBubble()` — Bulk invalidates all-groups cache for multiple users - `clearUnreadCount()` — Not implemented (throws `NotImplementedError`) --- ### `GroupRequestsService` Manages join requests: the full lifecycle from creation to acceptance/rejection, with automatic member joining on acceptance. - `createGroupRequest()` — Creates join request for a user (validates group is editable) - `createGroupRequestForGroupUser()` — Creates join request from a `GroupUser` object - `findLastGroupRequest()` — Fetches the most recent request for user+group combination - `findGroupRequestById()` — Fetches request by ID or throws `NotFoundError` - `cancelGroupRequest()` — Cancels (deletes) a pending join request - `getLastGroupRequestsMap()` — Batch fetches last request per group for a user - `listGroupRequests()` — Paginated list of join requests with filters - `getGroupRequestsCount()` — Total count of requests for a group - `hasPendingGroupRequests()` — Boolean check whether group has pending requests - `getGroupRequestCount()` — Count requests with optional status filter - `updateGroupRequestStatus()` — Changes status; auto-joins user as member if status is ACCEPTED - `updateAllGroupRequestsStatus()` — Bulk updates all pending requests; bulk joins members if ACCEPTED - `findAndUpdateGroupRequestStatus()` — Bulk-accepts pending requests for given user IDs; used when adding members to a group --- ### `GroupReportService` Report generation, permission validation, and progress locking for long-running report exports. **Report Generation** - `getGroupsInformationForReport()` — Gathers group data (admin counts, post counts) for reporting - `getBulkAddMembersOfGroupsReportTemplate()` — Returns Excel template for bulk member import - `getBulkAddMembersOfGroupsReportData()` — Extracts data rows from an uploaded Excel file - `getBulkAddMembersToGroupDataByXlsData()` — Processes XLS row data for batch member addition **Permission & Report Validation** - `validateAndPrepareGroupMembersReport()` — Validates permissions + in-progress check; returns group and report key - `validateAndPrepareGroupMostActiveMembersReport()` — Same but for most-active-members report **Report Progress Lock (Cache-based)** - `isReportInProgress()` — Checks Redis cache if a report is currently being generated - `setReportInProgress()` — Sets a Redis lock before starting report generation - `clearReportInProgress()` — Clears the Redis lock after completion --- ### `GroupPinnedGroupService` Manages a user's list of pinned groups with ordered positioning (fractional indexing, auto-compaction when precision degrades). - `pinGroup()` — Pins a group for a user; validates 100-group limit and deduplication - `reorderPinnedGroup()` — Reorders a pinned group's position; auto-compacts positions if precision degrades - `unPinGroup()` — Removes a group from the user's pinned list --- ### `GroupsUtilService` Shared guards and helpers used across other group services. Has no ports dependencies. Pure business logic. - `withGroupEditable()` — Runs a callback only if the group is not archived; throws `ForbiddenError` otherwise - `validateGroupInstanceGathering()` — Returns gathering ID for instance or throws `BadRequestError` - `userIsAdmin()` — Returns `true` if user has ADMIN role in group - `getUserPublicationApprovalStatus()` — Returns `PostApprovalStates` based on group approval policy and user role - `canEditGroupPostDirectly()` — Returns `true` if user's posts don't need approval - `validatePublicationPermission()` — Throws `ForbiddenError` if user can't post (ONLY_ADMINS policy + regular member) - `validateUserCanCreateEditPostRequest()` — Throws if user is not the post owner or group doesn't require approval --- ### `GroupsNotificationService` Coordinates all notification channels (email, push, notification center, real-time WebSocket) for group events. Reads user notification preferences per group and applies them. **Member Notifications** - `notifyGroupJoin()` — Notifies all members when a new user joins - `notifyGroupInvitation()` — Sends invitation emails/notifications to invited users - `sendGroupJoinNotification()` — Sends join notifications (email, NC, push) **Post Notifications** - `sendGroupPostNotificationOnCreation()` — Notifies members after post is created - `notifyGroupPostPublication()` — Sends publish notifications (email, push, NC) - `notifyPostEdition()` — Notifies of full post edit - `notifyPostPartialEdition()` — Notifies of partial post edit - `notifyGroupPostDeletion()` — Notifies of post deletion - `notifyUnpinGroupPost()` — Notifies of post unpin - `notifyPinGroupPost()` — Notifies of post pin **Comment/Reaction Notifications** - `notifyGroupPostCommentCreation()` — Notifies of new comment - `notifyGroupPostCommentEdition()` — Notifies of comment update - `notifyGroupPostCommentDeletion()` — Notifies of comment deletion - `notifyNewGroupPostReaction()` — Notifies post creator of new reaction - `notifyNewGroupPostPollAnswer()` — Notifies of poll response - `notifyGroupPostCommentReaction()` — Notifies of comment reaction - `sendDeletedGroupPostReactionNotifications()` — Notifies of reaction removal - `sendDeletedGroupPostCommentReactionNotification()` — Notifies of comment reaction removal **Approval Notifications** - `notifyGroupPostRequest()` — Notifies admins of pending edit requests - `notifyGroupPostRequestReviewed()` — Notifies requester of review decision - `notifyGroupPostRequestRejected()` — Notifies of rejection - `notifyGroupPostRequestApproved()` — Notifies of approval **Request Notifications** - `notifyUpdateGroupRequestStatus()` — Notifies of join request status change - `notifyUpdateAllPendingGroupRequests()` — Notifies bulk request updates - `notifyCancelGroupRequest()` — Notifies of join request cancellation **Group Notifications** - `notifyGroupCreation()` — Notifies of new group creation - `notifyGroupEdition()` — Notifies of group property changes - `notifyAdminDeleteGroup()` — Notifies of group deletion **Job Callbacks** - `bulkAddMembersAfterMethod()` — Job callback for bulk member addition notifications - `getUserNotificationConfiguration()` — Fetches user's notification preferences for a group --- ### `GroupStatsService` Group engagement statistics for the last 28 days. Reads from a stats cache port (pre-aggregated data) and enriches results with user data. - `listTop15ActiveMembersLast28Days()` — Returns top 15 most active members from cache for last 28 days; enriches each with user metadata - `getSummaryLast28Days()` — Returns current-period vs previous-period stats summary (views, reactions, posts) for last 28 days - `listMostActiveMembersLast28DaysForReport()` — Returns all most-active members without the 15-row cap, used for report exports --- ### `GroupsCDCService` Kafka CDC consumer extending `BaseCDCService`. Subscribes to the users and groups topics and keeps the `GroupsUsers` denormalized table in sync. Also cascades post deletion and red-bubble cache invalidation when a user or group is soft-deleted. - Subscribes to `kafkaUsersTopicName` and `kafkaGroupsTopicName` - **User CREATE/UPDATE/READ** → upserts row in `GroupsUsers` table - **User DELETE** → soft-deletes `GroupMembers`, decrements group `membersCount`, deletes user's posts, marks `GroupsUsers` as deleted - **Group CREATE/READ** → indexes the group entity in search (`ContentModule.GROUPS`) - **Group UPDATE** → re-indexes the group entity if a searchable field changed (`title`, `description`, `privacyPolicy`, `isMultiCompany`); if `privacyPolicy` crosses the OPEN boundary, re-indexes all `POSTED`+`APPROVED` group posts with the recomputed permission - **Group DELETE** → removes the group entity from search, invalidates red-bubble caches for all members (batched), soft-deletes all group posts (batched, also removed from search) - Dead-letter topic: `${kafkaGroupsGroupId}.dlt` for messages that fail parsing or processing --- ### `GroupPostSearchService` Light `@Service()` that owns the **group post** search document. Deps: `SearchService`, `DeepLinksService`, `LoggerPort`. Injected by both `GroupsNotificationService` (create/update/delete on the post lifecycle) and `GroupsCDCService` (privacy-change re-index). Keeping it separate avoids pulling the full `GroupsNotificationService` dependency tree into the CDC node. - `createInSearchService(post, group)` / `updateInSearchService(post, group)` — build and send the `GROUP_POSTS` document (returns early if `post.content` is missing). `group` is a `GroupSearchContext = Pick`, so callers can pass a full `Group` or a small object built from a CDC message. - `deleteGroupPostFromSearch(postId, instanceId)` — removes the `GROUP_POSTS` document. --- ### `GroupsLinkService` Generates HATEOAS-style edit action links for group posts based on the user's role and post state. - `getEditGroupPostLinksForGroupPost()` — Returns allowed edit/request-edit links for a single post - `getEditGroupPostLinksForGroupPosts()` — Returns `Map` for multiple posts --- ## Key Patterns ### Adding a New Feature 1. **Identify the service** using the Service Map table above. 2. **Define the port method** in `business/ports/` if new infrastructure access is needed. 3. **Implement the adapter** in `infrastructure/adapters/`. 4. **Add the business method** to the appropriate service. 5. **Add the endpoint** in `presentation/controllers/groupsController.ts`. 6. **Register the route** in the appropriate router function in `presentation/routers/groupsRouter.ts`. 7. **Add notification logic** in `GroupsNotificationService` and call it from the controller's post-transaction callback. ### Transaction Pattern Critical mutations use `repositoryPort.getTransaction()`: ```typescript await this.repositoryPort.getTransaction(async () => { await this.repositoryPort.someWrite(...); await this.repositoryPort.anotherWrite(...); }); ``` Notifications are sent after the transaction via the controller's `endpointHandlerWithGenerics` post-transaction callback — never inside the transaction. ### Validation Guards Before any write, check these in `GroupsUtilService`: - `withGroupEditable(group, fn)` — Ensures group is not archived - `validatePublicationPermission(group, userId)` — Ensures user can post ### Privacy Access Rules Visibility of group contents to **non-members** is gated by `group.privacyPolicy`: | Privacy | Group visible? | Posts / comments / reactions visible to non-members? | |---------|----------------|------------------------------------------------------| | `OPEN` | Yes | Yes — anyone in the instance can read | | `CLOSE` | Yes (group exists, joinable via request) | **No — members only** | | `SECRET`| No | **No — members only** | Only members can read post-level resources (posts, comments, reactions, polls, post stats) of a `CLOSE` or `SECRET` group. Non-members of `OPEN` can read; non-members of `CLOSE` / `SECRET` get `403 ForbiddenError` (`ErrorCodes.ACCESS_DENIED`). ### Red Bubble Cache — `GroupRedBubbleService` Unread-count "red bubbles" are cached in Redis because the underlying DB query (`countUnreadGroupPostsAfterPublicationDatetimeInGroup` / `…InAllGroups`) joins `GroupsPosts` and `UserLastTimeInModule` and applies a `NOT EXISTS` against `PostViews`. It is hot enough that every list-groups call (`GET /groups`) and every aggregate badge refresh (`GET /red-bubbles/groups`) goes through it, multiplied by every WebSocket-driven refetch. **Two cached numbers, never one.** Per user we keep: - The **per-group unread count** for each group the user has touched (badge on the group row). - The **module aggregate** total across all the user's groups (badge on the Groups tab). They have **separate DB queries** and **separate cache keys**. Any change to invalidation has to consider both. #### Key layout in Redis For user `185` member of groups `12, 34, 56`: ``` RED_BUBBLES:GROUP:185:0 → string ":" ← module aggregate RED_BUBBLES:GROUP:185:12 → string ":" ← per-group RED_BUBBLES:GROUP:185:34 → string ":" RED_BUBBLES:GROUP:185:56 → string ":" RED_BUBBLES:GROUP:ver:185 → HASH { "0": "", "12": "", ... } ``` - Value keys: one per scope, top-level strings. Each scope = a group id, or `0` for the module aggregate. Format: `":"`. - Version hash: **one per user**, fields per scope. Field `"0"` is the aggregate's version, field `""` is that group's. Centralising versions in a hash keeps Redis from growing one extra top-level key per (user, group); a user in 80 groups has 1 extra hash, not 80 extra keys (relevant since each field is just a small integer — listpack-encoded up to 128 fields). - TTL: `GROUPS_RED_BUBBLE_CACHE_TTL` env, default **3h** (do NOT reuse `GROUPS_CACHE_TTL` — TimeOff still consumes that one). Refreshed on every value write and on every version `HINCRBY`. #### Read flow 1. `cachePort.get(valueKey)` → raw string or null. 2. Parse the raw value with `parseVersionedValue`: - Has `:` → `{ count, version }`. Compare `version` against the current value of the hash field. Match → cache hit, return `count`. Mismatch → null (treat as miss → recompute). - No `:` → null (treat as miss). Special case: **legacy branch** below. - Anything else (null, malformed) → null. 3. On null → `calculateAndSet`: read versionAtStart, query DB, write `":"`. **No NX** — the write must overwrite stale/invalid contents so the cache self-heals on the first read. Reads do not block on each other. Concurrent computes for the same scope each run their own DB query and each write — they may overwrite each other with the same value or with version-mismatched values that the next read rejects. Stale-tagged writes are caught at read time by the version check, not at write time. `addUnreadCountOnGroups` (the listing hot path) batches the snapshot: one `MGET` for all value keys + one `HMGET` for all version fields, then resolves each group inline. **No per-group `HGET`** — the helper uses pre-snapshotted versions to avoid an O(N) round-trip pattern. #### Invalidation paths Every invalidator must bump the version for the affected scope. A `del` alone is not enough: a stale compute that started before the del would still write a value with the old version tag, and the version mismatch at read time is what rejects it. **All five invalidators are version-aware:** | Method | What it does | |---|---| | `clearUnreadCountOfId` | "Mark group as read." `upsertLastTimeInGroup` + `HINCRBY ver:{user} {group}` → pre-populate `"0:"` for the group; `del` aggregate value + `HINCRBY ver:{user} "0"`. | | `clearGroupUnreadCache` | `del` both value keys, pipelined `hincrByOneMany` over the per-group field and the `"0"` field on the shared `ver:{user}` hash. | | `clearGroupUnreadIfNewPost` | If the post is newer than the user's last visit, delegates to `clearGroupUnreadCache`. Otherwise no-op. | | `clearCacheOfGroupUsersRedBubble` | Bulk: `delMany` per-group value keys for the chunk + `hincrByOneMany` over the per-group field for each user. | | `clearCacheMainRedBubble` | Bulk: `delMany` aggregate value keys + `hincrByOneMany` over field `"0"` for each user. | The bulk fan-out callers (`GroupsNotificationService` post creation / deletion, `GroupsCDCService` on group/user delete, `GroupMembersService` on member deletion) chunk `userIds` upstream before invoking the bulk methods. `hincrByOneMany` chunks internally as a defensive second layer. **Why both per-group and `"0"`?** Per-group activity invalidates the aggregate (the sum changes), so a single event bumps both fields. But the two cached values race independently — on a home-screen load `getUnreadCount` and `addUnreadCountOnGroups` run in parallel — so each needs its own version-protected key. A single user-wide version counter would force over-invalidation of every other group whenever any one of them changes, multiplying recomputes of the heavier query. #### Backward-compat branch (transitional, SQSH-3860) Pre-versioning code wrote values as a bare integer string (`"5"`, no separator). Those keys can persist in Redis for up to 7 days after the SQSH-3860 deploy (matching the legacy `GROUPS_CACHE_TTL` override). The `resolveCachedCount` helper detects them via `LEGACY_PLAIN_COUNT_PATTERN` (`/^\d+$/`), serves the parsed count to the client as-is (it is the user's last cached count; close enough to be useful), and applies a random EXPIRE in `[1s, 300s]` to the key so the natural eviction is staggered across the population. After eviction the next read is a normal cache miss → recompute → write versioned value. **This branch is explicitly transitional.** It is dead code once all bare-integer keys have expired. Removal is tracked in [SQSH-3870]; see the `TODO(SQSH-3870)` comments in `groupRedBubbleService.ts`. ### Cache Invalidation (other modules) Any operation outside Groups that changes unread state must call `GroupRedBubbleService.clearGroupUnreadCache()`. Any new post must call `clearGroupUnreadIfNewPost()`. ### Notifications All notifications go through `GroupsNotificationService`. The controller calls the service method in the post-transaction callback of `endpointHandlerWithGenerics`. Never call notification methods inside a transaction. ### Search Indexing (Heimdall) **Group posts** are indexed in the search service (`SearchService`, module `ContentModule.GROUP_POSTS`). The document builder lives in `GroupPostSearchService` (a light `@Service()` injected by both `GroupsNotificationService` and `GroupsCDCService`). It mirrors feed posts' `postToSearchDocument` with these differences: `moduleId = String(group.id)`, `allowedEntityIds = ['group_']`, `permissionType = PUBLIC` when the group is `OPEN` else `MEMBERSHIP`, and `multicompany = group.isMultiCompany`. `instanceId` is the publishing instance (`post.instanceId`). | Trigger | Where | Notes | |---|---|---| | Create | `GroupsNotificationService.notifyGroupPostPublication` → `GroupPostSearchService.createInSearchService` | Also covers the approval→publish path (`notifyGroupPostRequestReviewed`). | | Update | `GroupsNotificationService.notifyPostEdition` → `GroupPostSearchService.updateInSearchService` | Loads the group via `repositoryPort.findGroupsByIds`. Approval-gated edits are **not** re-indexed (matches feed posts). | | Delete (single) | `GroupsNotificationService.notifyGroupPostDeletion` → `GroupPostSearchService.deleteGroupPostFromSearch` | Uses the deleting user's `instanceId`. | | Delete (bulk, user deleted) | `GroupPostService.deleteGroupPostsByUserIdInChunks` | Deletes each removed post from search using the user's `instanceId`. | | Delete (bulk, group deleted via CDC) | `GroupsCDCService.deleteGroupPostsInBatches` | `join`s `Posts` to read each post's own `instanceId` (multicompany-correct). | | Re-index (privacy change) | `GroupsCDCService.reindexApprovedPublishedGroupPostsPermission` | Fires only when a group `UPDATE` crosses the OPEN boundary `(before===OPEN) !== (after===OPEN)`. Pages `POSTED`+`APPROVED` posts (via `GroupPostService.getPostsMapByIds`) and re-sends each through `GroupPostSearchService.updateInSearchService`. `CLOSE↔SECRET` and no-op privacy changes do nothing. | **Group entity** is indexed in the search service (module `ContentModule.GROUPS`) by `GroupsCDCService` from the `Groups` CDC topic. Permission: `OPEN`/`CLOSE` → `PUBLIC`, `SECRET` → `MEMBERSHIP` — note this boundary differs from group **posts**, where only `OPEN` is `PUBLIC`. `allowedEntityIds = ['group_']`, `instanceId = group.instanceId` (NOT NULL, owner instance, incl. multicompany), `multicompany = group.isMultiCompany`, `title`/`content` from the group, `path = deepLinkService.getLinkToGroup(id)`, `createdAt`/`updatedAt` passed through from the CDC message, and `deleted = group.deletedAt !== null`. Requires `Groups` to be `REPLICA IDENTITY FULL` so `before`/`after` privacy is available on updates. > **No backfill of existing groups.** Group-entity indexing only takes effect going forward: the `kafkaGroupsGroupId` consumer group resumes from its committed offset on deploy, so the Debezium snapshot is not replayed. Existing groups require a separate one-off backfill to become searchable. All search calls are best-effort (fire-and-forget with `.catch` logging) so a search failure never breaks the primary flow. `GroupPostSearchService` (deps: `SearchService`, `DeepLinksService`, `LoggerPort`) centralizes the group-post document; `GroupsCDCService` also injects `SearchService` directly for the group-entity + bulk-delete calls and `DeepLinksService` for the group-entity `path` (the groups module depends on the search module — kept in sync in `module-deps.json`). --- ## Integration Tests Integration tests for this module live in: ``` humand-packages/monolith/test-integration/api/groups/ ``` See [`test-integration/api/groups/AGENTS.md`](../../../../../test-integration/api/groups/AGENTS.md) for the full file map and instructions on where to add new tests. **Key rules:** - Every test file must bootstrap its own community with `GenerallyAvailableInstanceCapabilityNames.VIEW_GROUPS` and populate `GroupsUsers` via raw SQL before running post or member queries. - New tests go in the file that matches the feature area (see the file map). If no file matches, create a new one. --- ## Keeping This Document Up to Date After every change to this module, check whether this file needs updating: - **New service method added** → add it to the relevant service section under Services — Detailed Reference. - **New service created** → add a row to the Service Map table and a new section under Services — Detailed Reference. - **Port or adapter added/removed** → update the Directory Structure counts. - **New pattern introduced** (new guard, new cache strategy, new notification channel) → add it under Key Patterns. - **Existing method renamed or removed** → update or remove the corresponding entry. The document is wrong if it describes code that no longer exists, or omits code that does. Keep it exact.