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

265 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```json
{
"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
```java
// 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-notification``skillhub-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
```yaml
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