Groups Module
Location: apps/api/src/modules/groups/
Aggregate roots: Group, GroupMember, GroupJoinRequest, GroupInvite
Topic-centred study/research collaboration spaces (ADR-005). A group has an owner, a join policy, an optional interest anchor, and a computed standing score — the group-level analogue of reputation.
Core Concepts
| Field | Values | Notes |
|---|---|---|
| kind | study | research | general | defaults to study |
| joinPolicy | open | request | invite | defaults to request |
| role | owner | moderator | member | one owner per group |
| memberCount | int | maintained transactionally on join/leave/remove |
Open groups join instantly; request/invite groups go through GroupJoinRequest (member-initiated) or GroupInvite (manager-initiated). Archive is a soft delete (archivedAt).
Group Standing
A rep-weighted "group reputation", computed per group at list/detail time (constants in groups.constants.ts):
score = posts × 6 + contributors × 10 + max(0, memberWeightSum − 1) × 3
memberWeight(member) = max(1, log₂(totalRep + 1))posts/contributorscome from group-scoped posts (Post.groupId), aggregated in the DB withgroupBy(one query for post counts, one by(groupId, authorId)for distinct contributors).memberWeightSumreadsreputation_snapshotsdirectly (cross-context Prisma read, no service import) — a group of respected experts ranks above one padded with empty accounts. Zero-rep members count as weight 1.- Tiers:
seedling(0+) →growing(40+) →established(150+) →pillar(400+).
HTTP Endpoints
All routes under /api/v1/groups/. Require JwtAuthGuard.
| Method | Path | Description |
|---|---|---|
| POST | /groups | Create a group (creator becomes owner) |
| GET | /groups?interestId=&q=&limit= | Discover groups (+ standing, isMember) |
| GET | /groups/mine | Groups the viewer belongs to |
| GET | /groups/:id | Detail + viewer relationship + standing |
| PATCH | /groups/:id | Owner/moderator edits metadata |
| DELETE | /groups/:id | Owner archives (soft delete) |
| POST | /groups/:id/join | Instant join (open groups only) |
| POST | /groups/:id/request | Request to join (request/invite) |
| DELETE | /groups/:id/request | Cancel my pending request |
| POST | /groups/requests/:requestId/respond | Approve/reject request (manager) |
| GET | /groups/:id/requests | Pending join requests (manager) |
| POST | /groups/:id/leave | Leave the group |
| DELETE | /groups/:id/members/:memberId | Remove a member (manager) |
| PATCH | /groups/:id/members/:memberId/role | Promote/demote moderator ⇄ member (owner) |
| POST | /groups/:id/invite | Invite a user (owner/moderator) |
| GET | /groups/:id/invites | Pending invites for the group (manager) |
| GET | /groups/invites/mine | Invites the viewer received |
| POST | /groups/invites/:inviteId/respond | Accept/decline (invitee) |
| DELETE | /groups/invites/:inviteId | Revoke a pending invite (manager) |
| POST | /groups/:id/transfer | Transfer ownership to a member (owner) |
Ownership Transfer
Only the owner can transfer. Atomic transaction: previous owner is demoted to moderator (keeps manage rights, can later leave), target member becomes owner, Group.ownerId updates. The owner can never leave directly — transfer or archive first.
Events Emitted
| Event | Payload |
|---|---|
groups.group.created | { groupId, ownerId } |
groups.member.joined | { groupId, memberId } |
groups.join.requested | { groupId, userId, requestId } |
groups.join.approved | { groupId, memberId } |
groups.invite.sent | { groupId, inviteId, inviterId, inviteeId } |
groups.invite.accepted | { groupId, inviteId, inviterId, memberId } |
groups.ownership.transferred | { groupId, previousOwnerId, newOwnerId } |
Notifications subscribes to the invite/request/ownership events.
Invariants
- Moderators can remove plain members only; only the owner removes moderators or sets roles.
memberCountis incremented only when a membership row is actually created (an approved request for someone who already accepted an invite must not drift the count).- Approve/accept paths use
upsert+$transaction— idempotent under races between requests and invites. - One pending request and one pending invite per
(group, user).
Admin
/admin/groups lists groups with archive/restore; /admin/groups/:id gives full detail management — remove members (POST /admin/groups/:id/members/:memberId/remove) and resolve join requests (POST /admin/groups/requests/:requestId/resolve). All actions are audit-logged.
See also: /modules/posts (group-scoped posts), /modules/reputation.