In-app notification system with real-time delivery via Server-Sent Events (SSE). Supports both human users and AI agents.
Activity Event (MCP tool / Server Action)
│
▼
activityService.createActivity()
│
▼
eventBus.emit("activity", payload)
│ ┌──────────────────────────┐
│ ┌─ Redis Pub/Sub (optional) ──▶ │ Other ECS instances │
│ │ channel: "synapse:events" │ receive & emit locally │
▼ │ └──────────────────────────┘
NotificationListener.handleActivity() ← src/services/notification-listener.ts
│ • Maps action → notification type
│ • Resolves recipients (assignees, stakeholders)
│ • Excludes self-notifications (actor ≠ recipient)
│ • Checks per-user preferences
▼
notificationService.createBatch() ← src/services/notification.service.ts
│ • Writes to DB (Notification table)
│ • Emits SSE event per recipient
▼
eventBus.emit("notification:<type>:<uuid>")
│
▼
SSE Endpoint → Browser EventSource → NotificationProvider → UI
The EventBus (src/lib/event-bus.ts) supports two modes:
| Mode | When | Transport | Config |
|---|---|---|---|
| In-memory | REDIS_URL not set |
Node.js EventEmitter only | Zero config (local dev default) |
| Redis Pub/Sub | REDIS_URL set |
EventEmitter + Redis SUBSCRIBE/PUBLISH |
REDIS_URL env var |
Redis mode enables cross-instance event delivery for multi-ECS-task deployments. All events are published to a single Redis channel (synapse:events) using SUBSCRIBE (not PSUBSCRIBE, which is unsupported on ElastiCache Serverless). Each message carries an _origin instance ID for deduplication — the originating instance skips its own messages from Redis since they were already delivered locally.
Production (CDK): ElastiCache Serverless Redis 7 with RBAC password authentication. Password stored in Secrets Manager, injected via REDIS_PASSWORD env var. Connection URL assembled at runtime: rediss://synapse:<password>@<endpoint>:6379.
Local dev (Docker Compose): redis:7-alpine with --requirepass. URL: redis://default:synapse-redis@localhost:6379.
| Field | Type | Description |
|---|---|---|
| uuid | string | Primary identifier |
| companyUuid | string | Multi-tenancy scope |
| projectUuid | string | Source project |
| projectName | string | Denormalized for display |
| recipientType | "user" | "agent" | Notification target type |
| recipientUuid | string | Target user/agent UUID |
| entityType | "task" | "idea" | "proposal" | "document" | Related entity |
| entityUuid | string | Related entity UUID |
| entityTitle | string | Denormalized title |
| action | string | Notification type (see below) |
| message | string | Human-readable message |
| actorType | "user" | "agent" | Who triggered it |
| actorUuid | string | Actor UUID |
| actorName | string | Denormalized actor name |
| readAt | datetime? | When marked as read |
| archivedAt | datetime? | When archived |
| createdAt | datetime | Creation timestamp |
Per-user/agent toggle for each notification type. All default to true.
| Field | Type | Default |
|---|---|---|
| taskAssigned | boolean | true |
| taskStatusChanged | boolean | true |
| taskVerified | boolean | true |
| taskReopened | boolean | true |
| proposalSubmitted | boolean | true |
| proposalApproved | boolean | true |
| proposalRejected | boolean | true |
| ideaClaimed | boolean | true |
| commentAdded | boolean | true |
| Action | Trigger | Recipients |
|---|---|---|
| task_assigned | Task assigned to user/agent | Assignee |
| task_status_changed | Task status updated | Assignee |
| task_verified | Admin verifies task (to_verify → done) | Assignee |
| task_reopened | Admin reopens a done/closed task | Assignee |
| task_submitted_for_verify | Developer submits task for verification | Project admins |
| proposal_submitted | PM submits proposal for review | Project admins |
| proposal_approved | Admin approves proposal | Proposal creator |
| proposal_rejected | Admin rejects proposal | Proposal creator |
| idea_claimed | Agent claims an idea | Idea creator |
| comment_added | Comment on idea/proposal/task/document | Entity assignee (excluding comment author) |
All endpoints require authentication (cookie session or Bearer token). All responses follow the standard format:
{ "success": true, "data": { ... } }
{ "success": false, "error": "Error message" }List notifications for the authenticated user/agent.
Query Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
| limit | number | 50 | Max items (1-100) |
| offset | number | 0 | Pagination offset |
| unreadOnly | "true" | - | Filter to unread only |
| projectUuid | string | - | Filter by project |
Response:
{
"success": true,
"data": {
"notifications": [
{
"uuid": "...",
"projectUuid": "...",
"projectName": "My Project",
"entityType": "task",
"entityUuid": "...",
"entityTitle": "Implement login page",
"action": "task_assigned",
"message": "Task assigned to you",
"actorType": "agent",
"actorUuid": "...",
"actorName": "PM Claude",
"readAt": null,
"createdAt": "2026-02-19T05:00:00.000Z"
}
],
"unreadCount": 3
}
}Mark a single notification as read. Only the recipient can mark their own notifications.
Response: Updated notification object.
Mark all notifications as read for the authenticated user/agent.
Request Body (optional):
{ "projectUuid": "..." }If projectUuid is provided, only marks notifications from that project as read.
Response:
{ "success": true, "data": { "count": 5 } }Archive a notification (soft-delete). Archived notifications are excluded from list queries by default.
Response: Updated notification object.
Get the unread notification count.
Response:
{ "success": true, "data": { "count": 3 } }Get notification preferences for the authenticated user/agent. Returns default (all true) if no preferences have been saved.
Response:
{
"success": true,
"data": {
"uuid": "...",
"ownerType": "user",
"ownerUuid": "...",
"taskAssigned": true,
"taskStatusChanged": true,
"taskVerified": true,
"taskReopened": true,
"proposalSubmitted": true,
"proposalApproved": true,
"proposalRejected": true,
"ideaClaimed": true,
"commentAdded": true
}
}Update notification preferences. Only include the fields you want to change.
Request Body:
{
"taskAssigned": false,
"commentAdded": false
}Response: Full updated preferences object.
Server-Sent Events stream for real-time notification delivery. Authenticates via session cookie (EventSource sends cookies automatically).
Event format:
data: {"unreadCount":5,"notification":{...}}
Connection lifecycle:
- Initial
: connectedcomment on connect : heartbeatcomment every 30 seconds- Auto-cleanup on client disconnect (abort signal)
- Client should reconnect on visibility change (handled by
NotificationProvider)
Event channel: notification:<type>:<uuid> (e.g., notification:user:abc-123)
Two public MCP tools are available for AI agents:
Input: { status?: "unread"|"read"|"all", limit?: number, offset?: number }
Output: { notifications: [...], unreadCount: number }
Input: { notificationUuid?: string, all?: boolean }
Output: { markedCount: number } or updated notification object
| Component | Location | Description |
|---|---|---|
| NotificationBell | src/components/notification-bell.tsx |
Sidebar bell icon with unread badge, opens Popover |
| NotificationPopup | src/components/notification-popup.tsx |
360px popover with Unread/All tabs, pagination |
| NotificationPreferencesForm | src/components/notification-preferences-form.tsx |
Settings page toggles, auto-save with 500ms debounce |
| NotificationProvider | src/contexts/notification-context.tsx |
SSE context provider, wraps dashboard layout |
When a comment is created on any entity (idea, task, proposal, document), the UI auto-refreshes the comment list for users currently viewing that entity — no manual refresh needed.
Comment created (Server Action / REST API / MCP tool)
│
▼
commentService.createComment()
│
├─▶ prisma.comment.create()
│
├─▶ eventBus.emitChange({ ← fire-and-forget SSE event
│ entityType: targetType,
│ entityUuid: targetUuid,
│ action: "updated",
│ actorUuid: authorUuid ← enables self-filtering
│ })
│
▼
SSE /api/events → Browser EventSource → RealtimeProvider
│
▼
useRealtimeEntityEvent(entityType, entityUuid, callback)
│ • Filters events by entityType + entityUuid
│ • Skips if actorUuid matches current user (already optimistically updated)
│ • Fires immediately (no throttle/debounce)
▼
Re-fetch comments via Server Action → UI updated
| Component | Entity Type | File |
|---|---|---|
| Idea Detail Panel | idea |
src/app/(dashboard)/projects/[uuid]/ideas/idea-detail-panel.tsx |
| Task Detail Panel | task |
src/app/(dashboard)/projects/[uuid]/tasks/task-detail-panel.tsx |
| Proposal Comments | proposal |
src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/proposal-comments.tsx |
The RealtimeEvent interface (src/lib/event-bus.ts) includes an optional actorUuid field:
interface RealtimeEvent {
companyUuid: string;
projectUuid: string;
entityType: "task" | "idea" | "proposal" | "document" | "project";
entityUuid: string;
action: "created" | "updated" | "deleted";
actorUuid?: string; // Who triggered the event — used by frontend to skip self-events
}Exported from src/contexts/realtime-context.tsx. Subscribes to SSE events filtered by entity type and UUID. Entity events bypass the throttle/debounce applied to generic useRealtimeEvent subscribers — comments appear instantly.
useRealtimeEntityEvent("task", task.uuid, (event) => {
if (event.actorUuid === currentUserUuid) return; // skip self
refetchComments();
});- Self-notification exclusion: Actors never receive notifications for their own actions (both at comment level and general activity level).
- Preference-aware: NotificationListener checks per-recipient preferences before creating notifications. Disabled types are silently skipped.
- Denormalized fields:
projectName,entityTitle,actorNameare stored on the notification row to avoid joins on read-heavy queries. - EventBus singleton: Uses
globalThisto ensure the same instance across Next.js module contexts (important for instrumentation + API routes sharing state). - Redis Pub/Sub: Optional cross-instance event delivery via ElastiCache Serverless. Uses single
SUBSCRIBEchannel (notPSUBSCRIBE) for cluster compatibility. Falls back to in-memory whenREDIS_URLis unset. - Deep linking: Notification clicks navigate to entity pages using query params for tasks (
?task=uuid) and ideas (?idea=uuid), or direct routes for proposals/documents.