Social Module
Location: apps/api/src/modules/social/
Aggregate roots: Circle, CircleMember, CircleInvitation, UserFollow, ConnectionIntent
The Social context handles the personal network layer: circles for private audiences, bidirectional consent-based membership, and follow graph.
Prisma Models
| Model | Table | Notes |
|---|---|---|
| Circle | circles | Owner-managed group with kind + name |
| CircleMember | circle_members | Accepted membership with bondLevel |
| CircleInvitation | circle_invitations | Pending invite before membership |
| UserFollow | user_follows | Unidirectional follow graph |
| UserMute | user_mutes | Silence another user |
| UserBlock | user_blocks | Bilateral silence |
| ConnectionIntent | connection_intents | Discovery intent (1 per user) |
Circle Fields
| Field | Type | Notes |
|---|---|---|
| id | UUID | |
| ownerId | UUID | |
| kind | String | general | family | work | inner | custom |
| name | String | |
| accent | String? | Color token |
Constraints: Max 10 members per circle enforced at service level. Max 10 custom circles per user (built-ins exempt). One invite per (circle, invitee, status).
Bond Levels
Each CircleMember carries a bondLevel: acquaintance (default) or inner-circle. Owners promote/demote members via PATCH /circles/me/:id/members/:memberId. Bond level drives inner-visibility posts (see Posts).
Key Methods
| Method | Description |
|---|---|
createCircle(userId, dto) | Create a custom circle (max 10 custom per user) |
addMember(circleId, memberId, ownerId) | Directly add accepted member |
sendInvitation(circleId, inviterId, inviteeId, bondLevel) | Two-step consent flow |
acceptInvitation(invitationId, userId) / declineInvitation(...) | Recipient responds |
revokeInvitation(invitationId, userId) | Owner cancels pending invite |
pendingInvitesTo(viewerId, targetId) | Sender-side: my outstanding invites to a user |
myMemberships(userId) | "Circles you're in" — others' circles I belong to |
removeMember(circleId, memberId, ownerId) | Remove a member |
getCircles(userId) | List all circles (owned + member of) |
follow(followerId, followingId) | Unidirectional follow |
unfollow(followerId, followingId) | Remove follow |
upsertIntent(userId, dto) | Set/update discovery intent |
getMyIntent(userId) | Fetch current intent |
HTTP Endpoints
All routes under /api/v1/circles/. Require JwtAuthGuard.
| Method | Path | Description |
|---|---|---|
| GET | /circles/me | List my circles (owned + member of) |
| POST | /circles/me | Create a circle |
| PATCH | /circles/me/:id | Update circle name/accent |
| DELETE | /circles/me/:id | Delete (owner only) |
| GET | /circles/me/:id/members | List members |
| POST | /circles/me/:id/members | Directly add member |
| PATCH | /circles/me/:id/members/:memberId | Update member bond level |
| DELETE | /circles/me/:id/members/:memberId | Remove member |
| POST | /circles/me/:id/invitations | Send invitation |
| GET | /circles/me/:id/invitations | List pending invites for a circle (owner) |
| DELETE | /circles/me/:id/invitations/:invitationId | Revoke (inviter only) |
| GET | /circles/invitations/me | Invitations addressed to me |
| POST | /circles/invitations/:invitationId/accept | Accept an invite I received |
| POST | /circles/invitations/:invitationId/decline | Decline an invite I received |
| GET | /circles/me/check/:userId | Do I share a circle with this user? |
| GET | /circles/me/pending/:userId | My outstanding invites sent to this user |
| GET | /circles/memberships/me | "Circles you're in" — circles owned by others that I belong to |
| POST | /circles/users/:id/follow | Follow a user |
| DELETE | /circles/users/:id/follow | Unfollow |
| GET | /circles/users/:id/follow-stats | Follower/following counts |
| GET | /circles/users/:id/followers | A user's followers |
| GET | /circles/users/:id/following | Who a user follows |
Circle Invite Visibility (both sides)
The consent flow is legible from both ends:
- Recipient side — the
circle.invitednotification (carryingpayload.invitationId) renders inline Confirm / Decline buttons in the mobile inbox, and a gold banner on the profile screen surfaces pending invitations addressed to me ("N circle invitations · tap to confirm or decline"). - Sender side —
GET /circles/me/pending/:userIdpowers an "Invite pending" signal under the Add to circle CTA and a tap-to-cancel Pending pill in the circle picker; the circles screen lists "Invitations sent · awaiting reply" with a cancel action, and "Circles you're in" answers whose / which circle am I in viaGET /circles/memberships/me.
Events Emitted
| Event | Payload | Consumed by |
|---|---|---|
social.member.added | { circleId, memberId } | Notifications |
social.member.removed | { circleId, memberId } | Notifications |
social.invitation.sent | { invitationId, circleId, inviterId, inviteeId } | Notifications |
social.invitation.accepted | { invitationId, circleId, memberId } | Notifications |
social.user.followed | { followerId, followingId } | Notifications |
Circle Invitation Flow
Owner → POST /circles/:id/invitations
→ CircleInvitation row status='pending'
→ Event: social.invitation.sent
→ Invitee receives notification
Invitee → POST /circles/invitations/:id/accept
→ CircleMember row created
→ Invitation status → 'accepted'
→ Event: social.invitation.accepted
→ Owner receives notification
Invitee → POST /circles/invitations/:id/decline
→ Invitation status → 'declined'
→ No membership createdPrivacy rule for DMs: Two users can only open a DM if they share at least one circle on either side or have an existing conversation.