Documentation source
Social Features
User profiles, connection requests, entity likes, entity sharing, in-app notifications, and comment reactions — the trust-graph layer for collaborative work.
## Overview
The Social Features module adds the identity and trust-graph layer that collaborative workflows depend on. It spans six surfaces: rich user profiles, a directed connection request state machine, entity likes with optimistic updates, entity sharing with a "Shared with me" inbox, in-app and email notifications with per-tenant preferences, and inline comment editing with emoji reactions. All surfaces are tenant-scoped and built on top of the platform's existing RLS model.
Module locations:
- `features/tenant/server/` — profile, connections, likes, password actions
- `features/tenant/hooks/` — client-side connection status
- `features/entities/server/shares.ts` — entity sharing + visibility
- `features/entities/components/` — LikeButton, LikedByPopover, ShareDialog, VisibilityBadge
- `features/entities/server/access.ts` — connection-scoped visibility
- `features/notifications/server/` — notification creation and preferences
- `features/comments/` — inline editing, reactions, @mention autocomplete
## Key Concepts
### User Profiles
The `profiles` table extends `auth.users` with social fields:
| Column | Type | Notes |
|---|---|---|
| `user_id` | uuid (PK, FK auth.users) | one-to-one with auth identity |
| `handle` | text | unique per tenant; `^[a-zA-Z0-9_]{3,20}$` |
| `display_name` | text | falls back to `email` in UI |
| `first_name` / `last_name` | text | |
| `photo_url` | text | avatar |
| `department` | text | org context |
A `SECURITY DEFINER` trigger (`on_auth_user_updated`) with `SET search_path = ''` synchronizes `auth.users` metadata changes to `profiles` on every update (including email-only changes). `updateProfile` also mirrors edits back into `auth.users` metadata so OAuth re-login cannot roll back manual edits.
### Connections
`user_connections` models a directed trust relationship:
```
requester_id → addressee_id, status: pending | accepted
```
Decline is a **DELETE** of the pending row (not a `status='blocked'` transition). This prevents permanent lockout. The uniqueness constraint is directional; a re-connection requires a new request. RLS restricts SELECT to the two participants; UPDATE is accept-only by the addressee.
**Status machine:**
```
[requester sends] → pending
[addressee accepts] → accepted
[either declines/cancels] → row deleted
```
### Entity Likes
`entity_likes(entity_id, user_id, tenant_id)` with a unique constraint per `(entity_id, user_id)`. `toggleLike` is idempotent: insert on first call, delete on second. The `<LikeButton>` component uses React Query optimistic updates so the UI responds immediately.
### Connection-Scoped Visibility
Entities have a `visibility` enum: `private | team | connections | public`. The `connections` level grants read access to any authenticated user who has an accepted connection with the entity owner. `assertEntityAccessForActor` in `features/entities/server/access.ts` checks both directions of the `user_connections` join before granting access.
### Entity Sharing
`entity_shares(entity_id, user_id, tenant_id, role, ...)`. `shareEntityWithUser` grants a specific user access to an entity; `removeEntityShare` revokes it. `updateEntityVisibility` and `generateShareToken` back the visibility control and share-link / embed flows. The `<ShareDialog>` component (mounted in the entity detail hero) provides the UI. Recipients see records shared with them in the **Shared with me** inbox at `/inbox/shared`, backed by `getSharedEntities` (reads `entity_shares` filtered to the current user).
### In-App Notifications
`notifications(id, user_id, tenant_id, type, title, body, link, read_at)`. Notification creation is handled exclusively through the server-only module `features/notifications/server/create-notification.ts` — it is NOT a `"use server"` action. This prevents client-reachable IDOR (injecting notifications into arbitrary users).
Triggers fire for:
- New connection request (`connection_request`)
- Accepted connection (`connection_accepted`)
- @mention in a comment (`mention`)
- New like on your entity (`like`)
The `<NotificationBell>` component is SSR-bridged (initial unread count from the server, React Query for client updates).
### Per-Tenant Notification Preferences
`user_notification_preferences(user_id, tenant_id, notification_type, channel, enabled)` with RLS scoped to `(user_id = auth.uid() AND tenant_id = get_active_tenant_id())`. This prevents a user from reading or writing preferences for tenants they have left.
### Comment Enhancements
**Inline editing** — `<SingleComment>` has an edit mode toggled by a pencil button (visible on own comments). `updateComment` validates body (≤10 000 chars, full Zod object schema) and enforces ownership server-side. The PATCH route at `/api/comments/[id]` uses a strict Zod object schema.
**Reactions** — emoji reactions are stored per comment. The `useComments` hook returns reactions alongside the thread.
**@mention autocomplete** — `mention.ts` parses handles with `/@([a-zA-Z0-9_]{3,20})\b/`. Profile resolution is always tenant-scoped via `user_tenants` membership filter — a same-handle user in another tenant does not receive notifications about entities they cannot access.
## How It Works
### Connection Request Flow
```
1. User A calls sendConnectionRequest(userBId)
→ inserts user_connections row (status='pending')
→ createNotification fires for User B
2. User B calls acceptConnectionRequest(requestId)
→ UPDATE user_connections SET status='accepted' WHERE addressee_id=uid
→ createNotification fires for User A
3. Either party calls declineConnectionRequest / cancelOrRemoveConnection
→ DELETE the pending/accepted row
```
The `useConnectionStatus(userId)` hook polls the current state and returns one of `none | pending_sent | pending_received | connected`.
### Entity Access with Connection Visibility
```
assertEntityAccessForActor(entityId, actor)
→ fetch entity visibility
→ if 'public': pass
→ if 'connections': check user_connections for accepted bi-directional link
→ if 'team': check user_tenants role ≥ viewer
→ if 'private': check entity.user_id = actor.id OR team-level permission
→ falls through to can_access_entity() for the canonical policy
```
### Notification Delivery
```
Server action mutates data
→ calls createNotification({ userId, tenantId, type, title, body, link })
→ inserts into notifications table
→ shouldSendEmailNotification(userId, tenantId) checks preferences
→ if email enabled: queues email via Inngest
```
`createNotification` validates `link` to reject absolute URLs (`http://`, `//`) — only relative links (starting with `/`) are allowed, preventing open-redirect injection.
## API Reference
### Profile
| Function | Location | Purpose |
|---|---|---|
| `getProfile(userId)` | `features/tenant/server/profile.ts` | Fetch profile for a user |
| `updateProfile(updates)` | Same | Update own profile; mirrors to auth metadata |
| `searchProfiles(query)` | Same | Search by handle/display name within tenant |
### Connections
| Function | Location | Purpose |
|---|---|---|
| `sendConnectionRequest(addresseeId)` | `features/tenant/server/connections.ts` | Create pending connection |
| `acceptConnectionRequest(requestId)` | Same | Accept; returns updated row |
| `declineConnectionRequest(requestId)` | Same | Delete pending row |
| `cancelOrRemoveConnection(connectionId)` | Same | Remove pending or accepted |
| `getConnectionStatus(userId)` | Same | Current status between caller and user |
| `listConnections()` | Same | All accepted connections for caller |
### Likes
| Function | Location | Purpose |
|---|---|---|
| `toggleLike(entityId)` | `features/tenant/server/likes.ts` | Insert or delete like (idempotent) |
| `getLikeStatus(entityId)` | Same | Whether caller has liked the entity |
| `getLikedByUsers(entityId)` | Same | List of users who liked (for popover) |
### Shares
| Function | Location | Purpose |
|---|---|---|
| `shareEntityWithUser(entityId, userId, role?)` | `features/entities/server/shares.ts` | Share an entity with a specific user |
| `removeEntityShare(entityId, userId)` | Same | Revoke a user's share |
| `updateEntityVisibility(entityId, visibility)` | Same | Change entity visibility level |
| `getSharedEntities()` | Same | List entities shared with the caller (Shared inbox) |
### Notifications
| Function | Location | Purpose |
|---|---|---|
| `createNotification(params)` | `features/notifications/server/create-notification.ts` | Insert notification (server-only) |
| `markNotificationRead(id)` | `features/notifications/server/actions.ts` | Mark single notification read |
| `markAllNotificationsRead()` | Same | Mark all read for caller+tenant |
| `getNotificationPreferences()` | `features/notifications/server/preferences.ts` | Get per-tenant prefs |
| `updateNotificationPreference(type, channel, enabled)` | Same | Toggle a preference |
### Hooks
| Hook | Purpose |
|---|---|
| `useConnectionStatus(userId)` | Current connection state + optimistic mutations |
### Components
| Component | Purpose |
|---|---|
| `<LikeButton entityId>` | Like toggle with count and optimistic state |
| `<LikedByPopover entityId>` | Hover popover listing users who liked |
| `<ShareDialog entityId>` | Entity sharing UI (visibility, add-people, share link / embed) |
| `<VisibilityBadge visibility>` | Pill showing current visibility level |
## For Agents
Agents do not currently have dedicated social tools. Relevant surfaces accessible via existing tools:
- `searchEntities` — activity feed includes `comment_added`, `entity_shared` events
- `getEntity` — returns `visibility` field; connection-scoped entities are accessible only if the acting user has an accepted connection with the owner
- Future: a `sendConnectionRequest` tool could be added to the entity tool group for agent-initiated trust bootstrapping
## Design Decisions
**Decline is a DELETE, not `status='blocked'`** — Using `blocked` as a terminal status created a permanent-lockout bug (two users could never reconnect after one declined). A DELETE keeps the state machine simple: any user can send a fresh request after a decline.
**`createNotification` is server-only, not `"use server"`** — Making it a client-reachable server action opened an IDOR: any authenticated user could inject arbitrary notifications into any other user's inbox by supplying a target `userId`/`tenantId`. Moving it to a plain server-only module (no `"use server"` directive) removes it from the PostgREST/Next.js action surface entirely.
**GUC-cache approach dropped** — An experiment with `STABLE` functions caching `is_tenant_member` results via `set_config()` was found to be non-deterministic: the planner may memoize `STABLE` functions and skip the `set_config()` side effect, producing intermittent RLS denials. The canonical `can_access_entity` / `can_create_entity` / `can_check_entity_update` delegation is restored as the single source of truth.
**Mention resolution is tenant-scoped** — Handles are not globally unique. A `profiles` lookup filtered only by `handle` would send notifications across tenant boundaries. The lookup is always joined through `user_tenants` to restrict resolution to members of the active tenant.
**Connection uniqueness is directional** — The `UNIQUE NULLS NOT DISTINCT (requester_id, addressee_id, tenant_id)` constraint is asymmetric. This means A→B and B→A can coexist (two simultaneous requests), which is handled gracefully at the application layer by the first accept resolving both.
## Related Modules
- **Comments** (`features/comments/`) — inline editing, reactions, and @mention notifications are part of this feature set; see [Comments](/docs/features/comments)
- **Feed** (`features/feed/`) — entity shares appear as feed items; see [Feed](/docs/features/feed)
- **Entity System** (`features/entities/`) — visibility enum, like button, and share dialog are attached to entity surfaces; see [Entity System](/docs/features/entity-system)
- **Multi-Tenant** (`features/tenant/`) — connection and profile actions are tenant-scoped; see [Multi-Tenant](/docs/features/multi-tenant)
- **Realtime** (`features/realtime/`) — notification bell and comment reactions use realtime subscriptions for live updates; see [Realtime](/docs/features/realtime)
- **Auth & Permissions** (`features/tenant/auth.ts`) — `requireAuth()` gates all social mutations; see [Auth & Permissions](/docs/features/auth-permissions)