skillhub/docs/superpowers/specs/2026-03-19-notification-system-design.md

12 KiB
Raw Permalink Blame History

Notification System Design

Goal

Build an independent in-app notification subsystem for SkillHub that delivers real-time notifications for skill lifecycle events (publish, review, promotion, report), with SSE push, user preference control, and extensibility for future third-party channels.

Scope

  • In scope: In-app notifications, SSE real-time push, notification preferences (category × channel), bell icon + dropdown + notification page, data cleanup
  • Out of scope: External channels (email, Feishu, DingTalk), migration of existing governance notifications, external webhook delivery

Architecture

Domain Services (existing)
    │ ApplicationEventPublisher
    ▼
Domain Events (existing + new)
    │
    ├── SearchIndexListener (existing)
    ├── StarCounterListener (existing)
    └── NotificationModule (NEW)
         ├── NotificationEventListener
         ├── RecipientResolver
         ├── NotificationPreferenceService (filter)
         ├── NotificationDispatcher (channel routing)
         ├── NotificationService (persist)
         └── SseEmitterManager (push)

The notification module consumes domain events via @TransactionalEventListener(phase = AFTER_COMMIT) + @Async("skillhubEventExecutor"), following the same pattern as existing listeners. The async executor pool (max 4 threads) is sufficient for the added load since notification processing is lightweight (DB insert + SSE push).

Data Model

notification

Column Type Description
id BIGSERIAL PK
recipient_id VARCHAR(128) NOT NULL Recipient user ID
category VARCHAR(32) NOT NULL PUBLISH / REVIEW / PROMOTION / REPORT
event_type VARCHAR(64) NOT NULL e.g. skill.published, review.approved
title VARCHAR(200) NOT NULL Human-readable title
body_json TEXT Structured payload (see body_json schema below)
entity_type VARCHAR(64) skill / review / report (for navigation)
entity_id BIGINT Associated entity ID
status VARCHAR(20) NOT NULL DEFAULT 'UNREAD' UNREAD / READ
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
read_at TIMESTAMPTZ

Indexes:

  • (recipient_id, created_at DESC) — notification list
  • (recipient_id, status, created_at DESC) — unread filter

notification_preference

Column Type Description
id BIGSERIAL PK
user_id VARCHAR(128) NOT NULL
category VARCHAR(32) NOT NULL PUBLISH / REVIEW / PROMOTION / REPORT
channel VARCHAR(32) NOT NULL IN_APP (future: EMAIL, FEISHU, DINGTALK)
enabled BOOLEAN NOT NULL DEFAULT TRUE
UNIQUE(user_id, category, channel)

Behavior: When no explicit preference exists, default to enabled = true for all categories on IN_APP channel. No pre-inserted rows needed.

body_json Schema

The title column stores an i18n message key (e.g. notification.review.approved). The body_json column stores interpolation parameters as JSON. The frontend renders the title via t(title, JSON.parse(bodyJson)).

Common envelope:

{
  "skillName": "generate-commit",
  "skillSlug": "generate-commit",
  "namespace": "team-ai",
  "version": "1.0.0",
  "actor": "admin"
}

Additional fields per event type:

  • review.*: reviewId, reason (for rejected)
  • promotion.*: promotionId, reason (for rejected)
  • report.*: reportId, action (for resolved: "resolved" / "dismissed" / "hidden" / "archived")

Domain Events

Existing (reuse)

  • SkillPublishedEvent(skillId, versionId, publisherId)

New Events

// Review flow
ReviewSubmittedEvent(reviewId, skillId, versionId, submitterId, namespaceId)
ReviewApprovedEvent(reviewId, skillId, versionId, reviewerId, submitterId)
ReviewRejectedEvent(reviewId, skillId, versionId, reviewerId, submitterId, reason)

// Promotion flow
PromotionSubmittedEvent(promotionId, skillId, versionId, submitterId)
PromotionApprovedEvent(promotionId, skillId, reviewerId, submitterId)
PromotionRejectedEvent(promotionId, skillId, reviewerId, submitterId, reason)

// Report flow
ReportSubmittedEvent(reportId, skillId, reporterId)
ReportResolvedEvent(reportId, skillId, handlerId, reporterId, action)

Domain Service Modifications

The following existing services need ApplicationEventPublisher injected and publishEvent() calls added:

Service Method Event to Publish
ReviewService.submitReview() After review record created ReviewSubmittedEvent
ReviewService.approveReview() After status set to APPROVED ReviewApprovedEvent
ReviewService.rejectReview() After status set to REJECTED ReviewRejectedEvent
PromotionService.submitPromotion() After promotion request created PromotionSubmittedEvent
PromotionService.approvePromotion() After promotion approved PromotionApprovedEvent
PromotionService.rejectPromotion() After promotion rejected PromotionRejectedEvent
SkillReportService.submitReport() After report created ReportSubmittedEvent
SkillReportService.resolveReport() / dismissReport() After report resolved ReportResolvedEvent

SkillPublishService already publishes SkillPublishedEvent — no change needed. SkillReportService does not currently inject ApplicationEventPublisher — it needs to be added.

New Repository Methods for Recipient Resolution

Repository New Method Purpose
NamespaceMemberRepository findByNamespaceIdAndRoleIn(Long nsId, Collection<NamespaceRole> roles) Find namespace ADMIN/OWNER for review.submitted
UserRoleBindingRepository findByRoleCode(String roleCode) Find platform SKILL_ADMIN users for promotion/report events

Event → Notification Mapping

category event_type Trigger Recipients
PUBLISH skill.published Skill auto-published Skill author
REVIEW review.submitted New version submitted for review Namespace ADMIN/OWNER
REVIEW review.approved Review approved Skill author (submitter)
REVIEW review.rejected Review rejected Skill author (submitter)
PROMOTION promotion.submitted Promotion request submitted Platform SKILL_ADMIN
PROMOTION promotion.approved Promotion approved Requester
PROMOTION promotion.rejected Promotion rejected Requester
REPORT report.submitted New report filed Platform SKILL_ADMIN
REPORT report.resolved Report resolved Reporter

Module Structure

New Maven module: skillhub-notification

Dependencies: skillhub-notificationskillhub-domain (domain events, entities, repositories). Both NotificationEventListener and RecipientResolver live in skillhub-app (following the existing pattern of SkillStarEventListener and SkillRatingEventListener), where they can access skillhub-notification services, skillhub-domain repositories, and skillhub-auth for role resolution. This avoids a cross-module dependency from skillhub-notification to skillhub-auth.

skillhub-notification/                    -- new module (depends on: skillhub-domain)
├── domain/
│   ├── Notification.java
│   ├── NotificationCategory.java        -- enum: PUBLISH, REVIEW, PROMOTION, REPORT
│   ├── NotificationChannel.java         -- enum: IN_APP (future: EMAIL, FEISHU...)
│   ├── NotificationPreference.java
│   ├── NotificationRepository.java
│   └── NotificationPreferenceRepository.java
├── service/
│   ├── NotificationService.java         -- CRUD: create, list, mark read, batch read, unread count
│   ├── NotificationPreferenceService.java  -- preference CRUD + default fallback
│   └── NotificationDispatcher.java      -- route by channel (currently IN_APP only)
└── sse/
    └── SseEmitterManager.java           -- manage SSE connections: register, push, heartbeat, cleanup

skillhub-app/                             -- existing module
└── listener/
    ├── NotificationEventListener.java   -- consume domain events, call RecipientResolver + Dispatcher
    └── RecipientResolver.java           -- resolve recipient list per event type (needs auth + domain repos)

SSE Real-Time Push

  • Endpoint: GET /api/notifications/sse
  • SseEmitterManager uses ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>> (thread-safe for concurrent tab open/close)
  • Per-user connection limit: max 5 emitters (reject new connections beyond limit)
  • Global connection limit: max 1000 emitters (configurable, reject with 503 when exceeded)
  • SseEmitter timeout: 60s, browser EventSource auto-reconnects
  • Heartbeat: :ping every 30s to prevent proxy/LB disconnection
  • On emitter complete/timeout/error: auto-remove from map
  • Push failure: silent ignore (notification already persisted, visible on refresh)
  • On EventSource reconnect: frontend fetches unread count to sync badge

API Design

GET    /api/notifications                  -- List (paginated + category filter)
GET    /api/notifications/unread-count     -- Unread count (for bell badge)
PUT    /api/notifications/{id}/read        -- Mark single as read
PUT    /api/notifications/read-all         -- Mark all as read
GET    /api/notifications/sse              -- SSE connection

GET    /api/notification-preferences       -- Get current user preferences
PUT    /api/notification-preferences       -- Batch update preferences

Response format follows existing SkillHub API conventions (code + data wrapper).

Frontend

Bell Component (global nav bar)

  • Bell icon in nav bar, left of user avatar
  • Red badge with unread count (> 99 shows "99+")
  • Click to expand dropdown

Dropdown List

  • Shows latest 5 notifications
  • Each item: title + relative time ("3 minutes ago")
  • Click item → navigate to entity page + mark as read
  • Footer: "View all notifications" link
  • Header: "Mark all as read" button

Notification Page (/dashboard/notifications)

  • Full notification list with pagination
  • Tab filter by category: All / Publish / Review / Promotion / Report
  • Batch mark all as read
  • Click to navigate

Preference Settings (/settings/notifications)

  • Grouped by category, each with toggle switch
  • Currently shows IN_APP channel column only
  • Future: expand to category × channel matrix when new channels are added

Data Cleanup

  • Scheduled task (@Scheduled) runs daily at 2:00 AM
  • Read notifications: retain 30 days
  • Unread notifications: retain 90 days
  • Retention periods configurable
  • Use ShedLock or database advisory lock to ensure single-instance execution in multi-pod deployments
  • If using ShedLock: add shedlock-spring + shedlock-provider-jdbc-template as new Maven dependencies

Configuration

skillhub:
  notification:
    sse-timeout: 60s
    sse-heartbeat: 30s
    cleanup:
      read-retention-days: 30
      unread-retention-days: 90

Relationship with Existing Governance Notifications

  • Existing GovernanceNotificationService + user_notification table remain untouched
  • New notification system runs independently in parallel
  • Existing governance notification UI (inside governance center) unchanged
  • Bell component reads only from new notification table
  • Future unification (migrating old data to new table) is out of scope for this phase

Extensibility

  • New event types: add domain event record + mapping in NotificationEventListener
  • New channels: add enum value to NotificationChannel + implement channel-specific dispatcher
  • Third-party integrations: add new @TransactionalEventListener beans that consume the same domain events
  • Preference table already supports category × channel granularity, no schema change needed