Left at /api/, because these are not ours to rename: - the vendor endpoint catalogues under products/warehouse_sources - the ingress route table in docs/internal/feature-flags, which really does send /api/* to Django - ActiveCampaign's own /api/3 path segment - the billing service's routes in the Vercel sequence diagrams - the Slack event and callback URLs, which are registered in the Slack app config and have to byte-match what the code still builds
15 KiB
NotificationEvent Architecture — Design Spec
Goal
Replace the per-recipient Notification model with a shared NotificationEvent + lazy NotificationReadState architecture.
Support targeting users, teams, organizations, and roles with pluggable recipient resolution.
Integrate with the access control system to filter notifications by resource type permissions.
Context
The current Notification model creates one DB row per recipient.
An org-wide notification to 1,000 users creates 1,000 rows + 1,000 Redis publishes.
This design reduces that to one row + one Redis publish regardless of org size.
Branch structure
The notification feature is split across three stacked PRs managed by Graphite:
| Branch | Base | PR | Scope |
|---|---|---|---|
yasen/notifications-models |
master |
#49311 | Models, migration, enums, product scaffold |
yasen/notifications-backend |
yasen/notifications-models |
#49312 | Backend logic, API, Go SSE handler |
yasen/notifications-frontend |
yasen/notifications-backend |
#49313 | Frontend popover UI |
Changes in this spec touch the first two branches.
After implementation, all three branches must be restacked with Graphite (gt restack).
Data Model
Branch: yasen/notifications-models
NotificationEvent
Replaces the existing Notification model (in-place migration — drop and recreate).
| Field | Type | Notes |
|---|---|---|
id |
UUIDField (PK) | From UUIDModel |
organization |
FK Organization | Always present. Used for channel routing |
team |
FK Team, nullable | Present for project-scoped notifications, null for org-level |
notification_type |
CharField | From NotificationType enum |
priority |
CharField | normal or urgent |
title |
CharField(255) | Display title |
body |
TextField | Display body, can be blank |
resource_type |
CharField, nullable | From NotificationResourceType enum. Used for AC filtering |
resource_id |
CharField, blank | Specific resource instance ID |
source_url |
CharField(500), blank | Deep link URL |
target_type |
CharField | From TargetType enum: user, team, organization, role |
target_id |
CharField | ID of the target entity |
resolved_user_ids |
JSONField | Snapshot array of user IDs, e.g., [1, 2, 3] |
created_at |
DateTimeField(auto_now_add) |
Indexes:
(organization, created_at DESC)— list query- GIN index on
resolved_user_ids— containment filter
Ordering: ["-created_at"]
NotificationReadState
New model. Row exists = read, no row = unread.
| Field | Type | Notes |
|---|---|---|
id |
UUIDField (PK) | From UUIDModel |
notification_event |
FK NotificationEvent, on_delete=CASCADE | |
user |
FK User, on_delete=CASCADE | |
created_at |
DateTimeField(auto_now_add) | When it was marked read |
Constraints:
- Unique together:
(notification_event, user)
Operations:
- Mark read →
get_or_create(notification_event=event, user=user) - Mark unread →
.filter(notification_event=event, user=user).delete() - Mark all read →
bulk_createrows for events without a read state - Is unread? → no
NotificationReadStaterow exists
Migration strategy
The PRs have not been deployed. The existing 0001_initial migration (which creates Notification) should be replaced with a new migration that creates NotificationEvent and NotificationReadState. Locally, the old notifications_notification table must be dropped or the migration must handle the transition (e.g., DeleteModel + CreateModel).
Update the IDOR semgrep rules to reference NotificationEvent instead of Notification.
Enums
Branch: yasen/notifications-models
TargetType (new)
class TargetType(str, Enum):
USER = "user"
TEAM = "team"
ORGANIZATION = "organization"
ROLE = "role"
NotificationResourceType (new)
Superset of access control resource types plus notification-specific types. AC filtering is applied only when the resource type has an AC counterpart.
class NotificationResourceType(str, Enum):
# AC resource types (filtering applied)
DASHBOARD = "dashboard"
EXPERIMENT = "experiment"
FEATURE_FLAG = "feature_flag"
INSIGHT = "insight"
NOTEBOOK = "notebook"
SESSION_RECORDING = "session_recording"
SURVEY = "survey"
ERROR_TRACKING = "error_tracking"
LOGS = "logs"
# Notification-only types (no AC filtering)
PIPELINE = "pipeline"
ALERT = "alert"
APPROVAL = "approval"
COMMENT = "comment"
NotificationType (updated)
Keep existing values: COMMENT_MENTION, ALERT_FIRING, APPROVAL_REQUESTED, APPROVAL_RESOLVED, PIPELINE_FAILURE, ISSUE_ASSIGNED.
Priority (unchanged)
NORMAL, URGENT.
Recipient Resolution
Branch: yasen/notifications-backend
RecipientsResolver
Base class with default implementation that resolves target_type + target_id to a list of user IDs.
class RecipientsResolver:
def resolve(self, target_type: TargetType, target_id: str, team_id: int) -> list[int]:
if target_type == TargetType.USER:
return [int(target_id)]
elif target_type == TargetType.TEAM:
return list(OrganizationMembership.objects.filter(
organization__teams__id=int(target_id)
).values_list("user_id", flat=True))
elif target_type == TargetType.ORGANIZATION:
return list(OrganizationMembership.objects.filter(
organization_id=target_id
).values_list("user_id", flat=True))
elif target_type == TargetType.ROLE:
return list(RoleMembership.objects.filter(
role_id=target_id
).values_list("user_id", flat=True))
return []
Custom resolvers
Callers can define their own resolvers for domain-specific recipient logic:
class ApprovalRecipientsResolver(RecipientsResolver):
def resolve(self, target_type, target_id, team_id):
policy = ApprovalPolicy.objects.get(id=target_id)
return [a.id for a in policy.approvers.all()]
Facade contract
@dataclass(frozen=True)
class NotificationData:
team_id: int
notification_type: NotificationType
title: str
body: str
target_type: TargetType
target_id: str
resource_type: NotificationResourceType | None = None
resource_id: str = ""
source_url: str = ""
priority: Priority = Priority.NORMAL
resolver: RecipientsResolver | None = None
Notification Creation Flow
Branch: yasen/notifications-backend
create_notification(data: NotificationData):
- Look up the team to get
organization_id - Feature flag check —
hanzo_insights.feature_enabled("real-time-notifications", ...)at the organization level. If disabled, return None. - Resolve recipients —
(data.resolver or RecipientsResolver()).resolve(data.target_type, data.target_id, data.team_id) - Create one
NotificationEventrow withresolved_user_idsset to the resolved list - On transaction commit — publish to Redis channel
notifications:{organization_id}
Redis Pub/Sub
Branch: yasen/notifications-backend
Channel structure
- Channel:
notifications:{organization_id}(one channel per organization) - No buffer — REST API handles catch-up on reconnect
Publish payload
{
"id": "event-uuid",
"notification_type": "alert_firing",
"priority": "urgent",
"title": "...",
"body": "...",
"resource_type": "dashboard",
"resource_id": "",
"source_url": "/project/3/dashboard/4",
"resolved_user_ids": [1, 2, 3],
"created_at": "ISO8601"
}
Fan-out
One publish per notification regardless of org size. The Go handler filters by user ID before forwarding via SSE.
Go Livestream Handler
Branch: yasen/notifications-backend
JWT changes
Add organization_id to the JWT claims (same pattern as the existing user_id claim added in the original implementation).
Handler changes
- Extract
organization_idanduser_idfrom JWT on SSE connect - Subscribe to
notifications:{organization_id} - On each message:
- Parse
resolved_user_idsfrom payload - Check if connected
user_idis in the array - If yes, forward the event via SSE (strip
resolved_user_idsfrom the payload sent to client) - If no, skip
- Parse
- Heartbeat: 15s
- Timeout: 30min
- No buffer replay — removed
Removed
- Buffer read on connect (
notification_buffer:*keys) - Per-user channel subscription
REST API
Branch: yasen/notifications-backend
Endpoint stays at /v1/environments/{team_id}/notifications/.
The viewset resolves the organization from the team ID.
List (GET /)
- Query
NotificationEvent.objects.filter(resolved_user_ids__contains=[request.user.id])scoped to the team's organization - Collect unique non-null
resource_typevalues from the result set - For each unique resource type that exists in the AC resource list, call
UserAccessControl.check_access_level_for_resource(resource_type, "viewer")— one query per unique type - Build a set of denied resource types
- Exclude events with a denied
resource_type - LEFT JOIN
NotificationReadStateforrequest.user— annotateread(boolean) based on row existence - Order by
-created_at, paginate (max 50)
Unread count (GET /unread_count/)
Same filtering as list, but COUNT where no NotificationReadState exists.
Mark read (POST /{id}/mark_read/)
NotificationReadState.objects.get_or_create(notification_event=event, user=request.user)
Mark unread (POST /{id}/mark_unread/)
NotificationReadState.objects.filter(notification_event=event, user=request.user).delete()
Mark all read (POST /mark_all_read/)
Find all NotificationEvent IDs for this user that have no NotificationReadState.
NotificationReadState.objects.bulk_create(...) for those events.
Serializer
Returns a flat object matching the existing InAppNotification frontend type:
{
"id": "event-uuid",
"notification_type": "alert_firing",
"priority": "urgent",
"title": "...",
"body": "...",
"read": false,
"read_at": null,
"resource_type": "dashboard",
"source_url": "/project/3/dashboard/4",
"created_at": "..."
}
read is computed from the join (true if NotificationReadState row exists, false otherwise).
read_at is NotificationReadState.created_at if exists, null otherwise.
The frontend does not need to know about the two-table split.
Access Control Integration
Branch: yasen/notifications-backend
Filtering strategy
AC filtering happens only in the REST API (not in SSE / Go handler).
The Go handler delivers all notifications where the user is in resolved_user_ids.
The badge count from SSE is "optimistic" — corrected when the popover opens and the REST API runs the AC check.
Optimization
Instead of checking AC per notification, collect unique resource_type values from the query result and run one UserAccessControl.check_access_level_for_resource() call per unique type. Typical result set has 3-5 unique types → 3-5 AC checks total.
Non-AC resource types
Notifications with resource_type set to a notification-only type (pipeline, alert, approval, comment) or resource_type=NULL skip AC filtering entirely — all targeted users see them.
Feature entitlement
AC filtering only applies when the organization has the ADVANCED_PERMISSIONS feature. UserAccessControl.access_controls_supported returns False without this feature, and check_access_level_for_resource returns the default access level (typically "editor"), so no notifications would be filtered out for orgs without the entitlement.
Frontend Impact
Branch: yasen/notifications-frontend
Minimal changes expected:
- The
InAppNotificationTypeScript type shape stays the same (serializer handles flattening) - The SSE payload shape stays the same (Go handler strips
resolved_user_ids) - No frontend model or component changes needed for this spec
- The
sidePanelNotificationsLogicSSE handler and REST API calls remain unchanged
Branch Change Summary
yasen/notifications-models (PR #49311)
- Replace
Notificationmodel withNotificationEvent+NotificationReadState - Replace migration
0001_initial - Add
TargetTypeandNotificationResourceTypeenums - Update facade contracts (
NotificationData) - Update IDOR semgrep rules
- Update app registration if model names changed
yasen/notifications-backend (PR #49312)
- Rewrite
create_notificationlogic for new model + recipient resolution - Add
RecipientsResolverbase class and default implementation - Update
_publish_to_redis— org-scoped channel, no buffer - Update REST API viewset — new query patterns, AC filtering, read state joins
- Update serializer — flatten two-table model to same response shape
- Go handler — subscribe to
notifications:{organization_id}, filter byuser_id in resolved_user_ids, remove buffer, stripresolved_user_idsfrom SSE payload - JWT — add
organization_idclaim - Update tests
yasen/notifications-frontend (PR #49313)
- No changes expected from this spec (serializer maintains same shape)
- Remove
notification_bufferreferences insidePanelNotificationsLogicif any
Graphite restack
After all changes are committed on the respective branches:
gt checkout yasen/notifications-models
gt restack
gt submit --no-interactive --publish
This propagates changes up through the stack: models → backend → frontend.