714 lines
30 KiB
Markdown
714 lines
30 KiB
Markdown
# DESIGN-0.20.0 — Notifications + @mention Routing + Multi-model
|
||
|
||
**Status:** Complete (Phases 1–3 delivered 2026-03-01)
|
||
**Depends on:** v0.19.2 (Projects complete), EventBus + WebSocket hub (v0.9.x), `channel_models` table (v0.16.0 schema)
|
||
**Prerequisite for:** v0.23.0 (Multi-Participant Channels — notifications become the backbone for participant events)
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
v0.20.0 delivers two complementary features:
|
||
|
||
1. **Notifications** — Persistent, user-targeted notification infrastructure with real-time WebSocket push and optional email transport. This is the event backbone for all future collaboration features (multi-participant channels, workflow assignment queues, presence).
|
||
|
||
2. **@mention Routing + Multi-model** — Channels can host multiple AI models. Users @mention a model (or user, for future multi-participant) to direct a message. The completion handler fans out to the addressed model(s). This builds on the existing `channel_models` table and `ChannelModel` store methods that have been in the schema since v0.16.0 but were never surfaced in UI.
|
||
|
||
---
|
||
|
||
## Phasing
|
||
|
||
| Phase | Scope | Migration | Est. Effort |
|
||
|-------|-------|-----------|-------------|
|
||
| **1** | Notifications core (in-app) | `007_v0200_notifications.sql` | Medium-large |
|
||
| **2** | @mention parsing + multi-model routing | None (uses existing `channel_models`) | Medium |
|
||
| **3** | Email transport + user notification preferences | `008_v0200_notification_prefs.sql` | Medium |
|
||
|
||
Each phase is a merge-ready deliverable. Phase 1 is the critical path — 2 and 3 can be reordered or deferred if needed.
|
||
|
||
---
|
||
|
||
## Phase 1 — Notifications Core (in-app)
|
||
|
||
### Data Model
|
||
|
||
```sql
|
||
-- Postgres: 007_v0200_notifications.sql
|
||
CREATE TABLE IF NOT EXISTS notifications (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
type VARCHAR(50) NOT NULL, -- e.g. 'role.fallback', 'kb.processing', 'grant.changed', 'project.invite'
|
||
title VARCHAR(255) NOT NULL,
|
||
body TEXT DEFAULT '',
|
||
resource_type VARCHAR(50), -- 'channel', 'knowledge_base', 'project', 'team', etc.
|
||
resource_id UUID, -- nullable — links notification to a navigable entity
|
||
is_read BOOLEAN DEFAULT false,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC);
|
||
CREATE INDEX idx_notifications_user_created ON notifications(user_id, created_at DESC);
|
||
```
|
||
|
||
```sql
|
||
-- SQLite: 006_v0200_notifications.sql
|
||
CREATE TABLE IF NOT EXISTS notifications (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
type TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
body TEXT DEFAULT '',
|
||
resource_type TEXT,
|
||
resource_id TEXT,
|
||
is_read INTEGER DEFAULT 0,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications(user_id, is_read, created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications(user_id, created_at DESC);
|
||
```
|
||
|
||
**Design notes:**
|
||
- No `updated_at` — notifications are immutable once created (only `is_read` flips).
|
||
- `type` is a free-form string, not an enum. New notification sources don't require migration. Convention: `domain.action` (e.g. `role.fallback`, `kb.ready`, `grant.added`, `project.invite`).
|
||
- `resource_type` + `resource_id` enable click-to-navigate. Frontend maps these to routes (e.g. `resource_type=channel` → `selectChat(resource_id)`).
|
||
- No FK on `resource_id` — the referenced entity may be deleted; notification persists as historical record.
|
||
|
||
### Store Interface
|
||
|
||
```go
|
||
// server/store/interfaces.go — addition to Stores
|
||
type NotificationStore interface {
|
||
Create(ctx context.Context, n *models.Notification) error
|
||
ListByUser(ctx context.Context, userID string, limit, offset int, unreadOnly bool) ([]models.Notification, int, error) // returns items + total count
|
||
MarkRead(ctx context.Context, id, userID string) error
|
||
MarkAllRead(ctx context.Context, userID string) error
|
||
Delete(ctx context.Context, id, userID string) error
|
||
UnreadCount(ctx context.Context, userID string) (int, error)
|
||
}
|
||
```
|
||
|
||
Both Postgres and SQLite implementations follow the established pattern. `ListByUser` returns paginated results with a total count for the unread badge. The `userID` parameter on `MarkRead`/`Delete` prevents cross-user access.
|
||
|
||
### Model
|
||
|
||
```go
|
||
// server/models/models_notification.go
|
||
type Notification struct {
|
||
ID string `json:"id" db:"id"`
|
||
UserID string `json:"user_id" db:"user_id"`
|
||
Type string `json:"type" db:"type"`
|
||
Title string `json:"title" db:"title"`
|
||
Body string `json:"body" db:"body"`
|
||
ResourceType string `json:"resource_type,omitempty" db:"resource_type"`
|
||
ResourceID string `json:"resource_id,omitempty" db:"resource_id"`
|
||
IsRead bool `json:"is_read" db:"is_read"`
|
||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||
}
|
||
```
|
||
|
||
### API Endpoints
|
||
|
||
```
|
||
GET /api/v1/notifications — list (paginated, ?unread_only=true, ?limit=20&offset=0)
|
||
GET /api/v1/notifications/unread-count — { "count": 5 }
|
||
PATCH /api/v1/notifications/:id/read — mark single read
|
||
POST /api/v1/notifications/mark-all-read — mark all read for user
|
||
DELETE /api/v1/notifications/:id — delete single
|
||
```
|
||
|
||
All endpoints are user-scoped (auth middleware injects `user_id`). No admin override — notifications are personal.
|
||
|
||
### Notification Service
|
||
|
||
Centralized creation + dispatch. All notification sources go through this service, never insert directly.
|
||
|
||
```go
|
||
// server/notifications/service.go
|
||
type Service struct {
|
||
store store.NotificationStore
|
||
hub *events.Hub
|
||
}
|
||
|
||
func (s *Service) Notify(ctx context.Context, n *models.Notification) error {
|
||
// 1. Persist
|
||
if err := s.store.Create(ctx, n); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 2. Real-time push via WebSocket (if user is online)
|
||
s.hub.SendToUser(n.UserID, events.Event{
|
||
Label: "notification.new",
|
||
Payload: events.MustJSON(n),
|
||
Ts: time.Now().UnixMilli(),
|
||
})
|
||
|
||
return nil
|
||
}
|
||
|
||
// NotifyMany fans out to multiple users (e.g. team-wide grant change).
|
||
func (s *Service) NotifyMany(ctx context.Context, userIDs []string, template models.Notification) error {
|
||
for _, uid := range userIDs {
|
||
n := template
|
||
n.ID = store.NewID()
|
||
n.UserID = uid
|
||
_ = s.Notify(ctx, &n) // best-effort; log errors, don't fail batch
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
### EventBus Route Table Addition
|
||
|
||
```go
|
||
// server/events/types.go — add to routeTable
|
||
"notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based
|
||
"notification.read": DirToClient, // badge sync across tabs
|
||
```
|
||
|
||
### Initial Notification Sources
|
||
|
||
Wire these into existing code paths. Each source creates a `Notification` via `Service.Notify()`:
|
||
|
||
| Source | Type | Trigger Point | Title Template |
|
||
|--------|------|---------------|----------------|
|
||
| Role fallback | `role.fallback` | `capabilities/resolver.go` (already emits EventBus) | "Model fallback: {primary} → {fallback}" |
|
||
| KB processing complete | `kb.ready` | `knowledge_bases.go` → after indexing pipeline | "Knowledge base '{name}' ready ({n} chunks)" |
|
||
| KB processing error | `kb.error` | `knowledge_bases.go` → on embedding/chunking failure | "Knowledge base '{name}' indexing failed" |
|
||
| Group membership changed | `grant.changed` | `groups.go` → AddMember / RemoveMember | "You were added to group '{name}'" |
|
||
| Project invite | `project.invite` | Future (v0.23.0+) — stub the type now | — |
|
||
|
||
**Implementation order:** `role.fallback` first (already emits an EventBus event — just subscribe in notification service), then `kb.ready`/`kb.error` (high user value), then `grant.changed`.
|
||
|
||
### Frontend — Notification UI
|
||
|
||
**Bell icon** (header bar, right of model selector):
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────┐
|
||
│ ☰ Chat Switchboard [model ▾] 🔔③ 👤 Jeff │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- `🔔` with `.notification-badge` (red circle, count) when `unreadCount > 0`.
|
||
- Badge shows count capped at `9+`.
|
||
- On click: toggle notification dropdown.
|
||
|
||
**Notification dropdown:**
|
||
|
||
```
|
||
┌──────────────────────────────────────┐
|
||
│ Notifications Mark all ✓ │
|
||
├──────────────────────────────────────┤
|
||
│ ● KB "Sales Docs" ready (3 chunks) │
|
||
│ 2 minutes ago │
|
||
├──────────────────────────────────────┤
|
||
│ ○ Model fallback: gpt-4 → gpt-3.5 │
|
||
│ 1 hour ago │
|
||
├──────────────────────────────────────┤
|
||
│ ○ Added to group "Engineering" │
|
||
│ Yesterday │
|
||
├──────────────────────────────────────┤
|
||
│ View all → │
|
||
└──────────────────────────────────────┘
|
||
```
|
||
|
||
- `●` = unread, `○` = read. Unread items have subtle background highlight.
|
||
- Click item: mark read + navigate to `resource_type`/`resource_id` (open chat, open KB in admin, etc.).
|
||
- "Mark all ✓" → `POST /notifications/mark-all-read` → clear badge.
|
||
- "View all →" opens full notification list in side panel (registered with `PanelRegistry`, same pattern as Notes/Preview/Project).
|
||
- Dropdown max-height: 400px, scrollable. Shows latest 10 items.
|
||
- Click-outside / Escape to close.
|
||
|
||
**File:** `src/js/notifications.js` (new module, ~300-400 lines)
|
||
|
||
**WebSocket handler:**
|
||
|
||
```javascript
|
||
Events.on('notification.new', (payload) => {
|
||
App.notifications.unshift(payload);
|
||
App.unreadNotificationCount++;
|
||
renderNotificationBadge();
|
||
|
||
// Optional: toast for high-priority notification types
|
||
if (['kb.error', 'role.fallback'].includes(payload.type)) {
|
||
UI.toast(payload.title, 'warning');
|
||
}
|
||
});
|
||
```
|
||
|
||
**Startup:** `GET /notifications/unread-count` on app init → set badge. Lazy-load full list on first dropdown open.
|
||
|
||
### Files Changed (Phase 1)
|
||
|
||
**New files:**
|
||
- `server/notifications/service.go` — notification service
|
||
- `server/handlers/notifications.go` — API handlers
|
||
- `server/store/postgres/notification.go` — Postgres store
|
||
- `server/store/sqlite/notification.go` — SQLite store
|
||
- `server/models/models_notification.go` — model struct
|
||
- `server/database/migrations/007_v0200_notifications.sql` — Postgres migration
|
||
- `server/database/migrations/sqlite/006_v0200_notifications.sql` — SQLite migration
|
||
- `src/js/notifications.js` — frontend notification UI
|
||
|
||
**Modified files:**
|
||
- `server/store/interfaces.go` — add `NotificationStore` to `Stores`
|
||
- `server/events/types.go` — add `notification.*` routes
|
||
- `server/main.go` (or `server/routes.go`) — wire notification handler + service
|
||
- `server/handlers/knowledge_bases.go` — emit `kb.ready` / `kb.error` notifications
|
||
- `server/handlers/groups.go` — emit `grant.changed` notifications
|
||
- `server/capabilities/resolver.go` — emit `role.fallback` notification (subscribe to existing EventBus event)
|
||
- `src/js/app.js` — init notification module, add bell to header
|
||
- `src/js/events.js` — add `notification.*` to known labels (documentation only, wildcard already works)
|
||
- `src/css/style.css` — notification badge, dropdown, panel styles
|
||
- `index.html` — notification bell element in header
|
||
- Integration tests — notification CRUD + WebSocket delivery
|
||
|
||
### Checklist (Phase 1)
|
||
|
||
- [x] Migration: `notifications` table (Postgres + SQLite)
|
||
- [x] Model: `Notification` struct
|
||
- [x] Store: `NotificationStore` interface + both implementations
|
||
- [x] Service: `notifications.Service` with `Notify()` / `NotifyMany()`
|
||
- [x] Handlers: 5 notification endpoints
|
||
- [x] EventBus: `notification.new` + `notification.read` in route table
|
||
- [x] Wire: notification service into knowledge_bases, groups, capabilities/resolver
|
||
- [x] Frontend: `notifications.js` module
|
||
- [x] Frontend: bell icon + unread badge in header
|
||
- [x] Frontend: notification dropdown (list, mark read, navigate)
|
||
- [x] Frontend: notification panel (full list, registered with PanelRegistry)
|
||
- [x] Frontend: WebSocket handler for real-time push
|
||
- [x] Frontend: toast for high-priority notification types
|
||
- [x] Tests: notification store CRUD (both dialects)
|
||
- [x] Tests: notification handler integration tests
|
||
- [x] Tests: WebSocket delivery (via existing hub test pattern)
|
||
|
||
---
|
||
|
||
## Phase 2 — @mention Parsing + Multi-model Routing
|
||
|
||
### Concept
|
||
|
||
Today, each channel has one active model (set via model selector or persona). The `channel_models` table (schema 001) already supports multiple models per channel — it's just never been populated with more than one, and the completion handler ignores it.
|
||
|
||
Phase 2 activates this table:
|
||
|
||
1. Users can **add models** to a channel (up to N, configurable, default 5).
|
||
2. Messages can contain **@mentions** that route to specific models.
|
||
3. The completion handler **fans out** to mentioned model(s), producing one assistant response per model.
|
||
4. Without @mention, the **default** channel model responds (backward compatible).
|
||
|
||
### @mention Syntax
|
||
|
||
```
|
||
@claude-3-opus What do you think about this approach?
|
||
@gpt-4 Can you review the code above?
|
||
Hey @claude-3-opus and @gpt-4, compare your approaches.
|
||
```
|
||
|
||
Mentions resolve against the `display_name` of `channel_models` entries for the current channel. Resolution is case-insensitive, whitespace-normalized, with longest-match-first to handle model names that are substrings of others.
|
||
|
||
**Why display_name, not model_id?** Model IDs are composite strings like `anthropic/claude-3-opus-20240229` — terrible UX. `display_name` is user-settable when adding a model to the channel, defaulting to a short friendly name derived from the model catalog (e.g. "Claude 3 Opus", "GPT-4").
|
||
|
||
### Mention Parser
|
||
|
||
```go
|
||
// server/mentions/parser.go
|
||
|
||
type Mention struct {
|
||
Raw string // "@claude-3-opus" as written
|
||
Name string // "claude-3-opus" (normalized, no @)
|
||
Start int // byte offset in message content
|
||
End int // byte offset end
|
||
Resolved *models.ChannelModel // nil if unresolved
|
||
}
|
||
|
||
// Parse extracts @mentions from message content and resolves them
|
||
// against the channel's model roster.
|
||
func Parse(content string, roster []models.ChannelModel) []Mention
|
||
```
|
||
|
||
**Parser rules:**
|
||
- `@` followed by one or more non-whitespace characters.
|
||
- Greedy: `@claude-3-opus-20240229` matches the full string, not just `@claude`.
|
||
- Resolution: case-insensitive match against `display_name` with hyphens/spaces normalized.
|
||
- Unresolved mentions are left as-is in the message (no error, no routing).
|
||
- Mentions at any position in the message (start, middle, end).
|
||
|
||
**Frontend-side autocomplete:** When user types `@` in the chat input, show a dropdown of channel models (fetched from `GET /channels/:id/models`). Selecting inserts the `@display_name` token. This reuses the `[[wikilink` autocomplete pattern from CM6 (v0.17.3) adapted for the plain textarea/CM6 chat input.
|
||
|
||
### Completion Handler Changes
|
||
|
||
Current flow (simplified):
|
||
```
|
||
user message → resolve model → one completion → one assistant message
|
||
```
|
||
|
||
New flow:
|
||
```
|
||
user message → parse @mentions → resolve target model(s) → fan out completions → N assistant messages
|
||
```
|
||
|
||
```go
|
||
// In completion.go, after persisting user message:
|
||
|
||
// 1. Load channel model roster
|
||
roster, _ := h.stores.GetModels(ctx, channel.ID)
|
||
|
||
// 2. Parse mentions
|
||
mentions := mentions.Parse(req.Content, roster)
|
||
|
||
// 3. Determine target models
|
||
var targets []models.ChannelModel
|
||
if len(mentions) > 0 {
|
||
// Deduplicate resolved mentions
|
||
seen := map[string]bool{}
|
||
for _, m := range mentions {
|
||
if m.Resolved != nil && !seen[m.Resolved.ID] {
|
||
targets = append(targets, *m.Resolved)
|
||
seen[m.Resolved.ID] = true
|
||
}
|
||
}
|
||
}
|
||
if len(targets) == 0 {
|
||
// No mentions or none resolved → use default channel model (current behavior)
|
||
targets = []models.ChannelModel{defaultModelFromRequest(req, channel, roster)}
|
||
}
|
||
|
||
// 4. Fan out: one completion per target model
|
||
for _, target := range targets {
|
||
// Each gets its own assistant message with model attribution
|
||
go h.completeForModel(ctx, c, req, channel, messages, target)
|
||
}
|
||
```
|
||
|
||
**Sequential vs parallel:** Start with **sequential** fan-out (simpler error handling, predictable message ordering). Parallel is a future optimization — the bottleneck is provider latency, not local compute.
|
||
|
||
**Streaming:** Each model's response streams independently. Frontend receives SSE events tagged with model info:
|
||
|
||
```json
|
||
{"event": "delta", "model": "claude-3-opus", "model_display": "Claude 3 Opus", "content": "..."}
|
||
```
|
||
|
||
Frontend renders each model's response in a separate message bubble with a model attribution label.
|
||
|
||
### Multi-model Channel UI
|
||
|
||
**"Add model" button** in the model selector area (chat header):
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────┐
|
||
│ Channel: Project Discussion │
|
||
│ Models: [Claude 3 Opus ✕] [GPT-4 ✕] [+ Add model] │
|
||
└──────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- Model pills show `display_name`, click `✕` to remove.
|
||
- `[+ Add model]` opens a dropdown of available models (filtered by user's accessible provider configs), with a "Display name" input field.
|
||
- Default model indicated with a subtle star/highlight (the one that responds without @mention).
|
||
- Click a model pill to set it as default.
|
||
|
||
**API endpoints:**
|
||
|
||
```
|
||
GET /api/v1/channels/:id/models — list channel models
|
||
POST /api/v1/channels/:id/models — add model to channel
|
||
DELETE /api/v1/channels/:id/models/:modelId — remove model
|
||
PATCH /api/v1/channels/:id/models/:modelId — update display_name, set default, system_prompt
|
||
```
|
||
|
||
These are mostly wrappers around the existing `SetModel`/`GetModels` store methods, with additional CRUD.
|
||
|
||
### Message Attribution
|
||
|
||
Assistant messages from multi-model channels get a visual attribution:
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ Claude 3 Opus │
|
||
│ I think the recursive approach is... │
|
||
├─────────────────────────────────────────┤
|
||
│ GPT-4 │
|
||
│ I'd suggest an iterative solution... │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
The `model` field on `messages` already stores the model ID. Frontend renders a label above multi-model assistant messages using the `display_name` from the channel model roster.
|
||
|
||
### Files Changed (Phase 2)
|
||
|
||
**New files:**
|
||
- `server/mentions/parser.go` — @mention parser
|
||
- `server/mentions/parser_test.go` — parser unit tests
|
||
- `server/handlers/channel_models.go` — channel model CRUD endpoints
|
||
|
||
**Modified files:**
|
||
- `server/handlers/completion.go` — fan-out logic, mention parsing integration
|
||
- `server/handlers/channels.go` — wire channel model routes
|
||
- `server/store/interfaces.go` — extend `ChannelStore` with `DeleteModel`, `UpdateModel` (if not already present)
|
||
- `server/store/postgres/channel.go` — additional model CRUD
|
||
- `server/store/sqlite/channel.go` — same
|
||
- `src/js/chat.js` — model pills UI, @mention autocomplete, multi-model message rendering
|
||
- `src/js/api.js` — channel model API methods
|
||
- `src/css/style.css` — model pill styling, attribution labels
|
||
|
||
### Checklist (Phase 2)
|
||
|
||
- [x] Mention parser: `mentions.Parse()` with case-insensitive resolution
|
||
- [x] Parser tests: edge cases (no mentions, unresolved, multiple, overlapping names)
|
||
- [x] Channel model CRUD handlers (4 endpoints)
|
||
- [x] Store: `DeleteModel`, `UpdateModel` for both dialects
|
||
- [x] Completion handler: mention extraction → target resolution → fan-out
|
||
- [x] SSE streaming: model attribution in delta events
|
||
- [x] Frontend: model pills in chat header
|
||
- [x] Frontend: add/remove model UI
|
||
- [x] Frontend: @mention autocomplete in chat input
|
||
- [x] Frontend: model attribution labels on assistant messages
|
||
- [x] Frontend: multi-stream rendering (sequential responses, distinct bubbles)
|
||
- [x] Integration tests: multi-model completion fan-out
|
||
- [x] Integration tests: channel model CRUD
|
||
- [x] Backward compat: channels with no `channel_models` rows work exactly as today
|
||
|
||
---
|
||
|
||
## Phase 3 — Email Transport + Notification Preferences
|
||
|
||
### User Notification Preferences
|
||
|
||
```sql
|
||
-- Postgres: 008_v0200_notification_prefs.sql
|
||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
type VARCHAR(50) NOT NULL, -- notification type or '*' for default
|
||
in_app BOOLEAN DEFAULT true,
|
||
email BOOLEAN DEFAULT false,
|
||
UNIQUE(user_id, type)
|
||
);
|
||
```
|
||
|
||
```sql
|
||
-- SQLite: 007_v0200_notification_prefs.sql
|
||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
type TEXT NOT NULL,
|
||
in_app INTEGER DEFAULT 1,
|
||
email INTEGER DEFAULT 0,
|
||
UNIQUE(user_id, type)
|
||
);
|
||
```
|
||
|
||
**Resolution chain:** Specific type preference → user's `*` default → system default (in_app=true, email=false).
|
||
|
||
### SMTP Configuration
|
||
|
||
Admin settings (stored in `platform_settings` JSONB, same pattern as existing admin config):
|
||
|
||
```json
|
||
{
|
||
"notifications": {
|
||
"email_enabled": false,
|
||
"smtp_host": "",
|
||
"smtp_port": 587,
|
||
"smtp_user": "",
|
||
"smtp_password": "", // encrypted via vault
|
||
"smtp_from": "noreply@chat.example.com",
|
||
"smtp_tls": true,
|
||
"instance_name": "Chat Switchboard",
|
||
"digest_enabled": false,
|
||
"digest_interval": "daily" // "hourly" | "daily" | "weekly"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Implementation:** `server/notifications/email.go` — wraps `net/smtp` with TLS support. Template rendering via Go `html/template` (no external dependency). Two templates per notification type: HTML + plaintext fallback.
|
||
|
||
### Email Service
|
||
|
||
```go
|
||
// server/notifications/email.go
|
||
type EmailTransport struct {
|
||
config SMTPConfig
|
||
vault *crypto.KeyResolver // decrypt SMTP password
|
||
}
|
||
|
||
func (t *EmailTransport) Send(ctx context.Context, to, subject, htmlBody, textBody string) error
|
||
```
|
||
|
||
### Notification Service Updates
|
||
|
||
The `Service.Notify()` method gains a preference check:
|
||
|
||
```go
|
||
func (s *Service) Notify(ctx context.Context, n *models.Notification) error {
|
||
prefs := s.resolvePrefs(ctx, n.UserID, n.Type)
|
||
|
||
if prefs.InApp {
|
||
s.store.Create(ctx, n)
|
||
s.hub.SendToUser(n.UserID, ...)
|
||
}
|
||
|
||
if prefs.Email && s.email != nil {
|
||
user, _ := s.stores.GetUser(ctx, n.UserID)
|
||
if user.Email != "" {
|
||
s.email.Send(ctx, user.Email, n.Title, renderHTML(n), renderText(n))
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
```
|
||
|
||
### Digest Mode
|
||
|
||
Low-priority notifications (e.g. `grant.changed`) can batch into a periodic email digest:
|
||
|
||
- Background goroutine checks on interval (hourly/daily).
|
||
- Queries `notifications WHERE is_read = false AND created_at > last_digest_at`.
|
||
- Groups by user, renders a single digest email per user.
|
||
- Marks digested notifications with a `digested_at` timestamp (add column in this migration).
|
||
|
||
**Deferred if complexity is too high for v0.20.0.** The core value is in-app + individual email. Digest is nice-to-have.
|
||
|
||
### User Preferences UI
|
||
|
||
Settings → Notifications:
|
||
|
||
```
|
||
┌────────────────────────────────────────────────┐
|
||
│ Notification Preferences │
|
||
├────────────────────────────────────────────────┤
|
||
│ Type In-App Email │
|
||
│ ───────────────────── ─────── ───── │
|
||
│ Model fallback [✓] [ ] │
|
||
│ KB processing [✓] [✓] │
|
||
│ Group membership [✓] [ ] │
|
||
│ Default (all others) [✓] [ ] │
|
||
└────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### Admin Settings UI
|
||
|
||
Admin panel → System → Notifications:
|
||
|
||
- Enable/disable email transport globally.
|
||
- SMTP configuration form (host, port, user, password, from address, TLS toggle).
|
||
- Test email button (sends to current admin's email).
|
||
- Digest toggle + interval selector.
|
||
|
||
### Files Changed (Phase 3)
|
||
|
||
**New files:**
|
||
- `server/notifications/email.go` — SMTP transport
|
||
- `server/notifications/templates.go` — HTML/text email templates
|
||
- `server/notifications/digest.go` — digest aggregation (if not deferred)
|
||
- `server/store/postgres/notification_prefs.go` — preference store
|
||
- `server/store/sqlite/notification_prefs.go` — same
|
||
- `server/database/migrations/008_v0200_notification_prefs.sql`
|
||
- `server/database/migrations/sqlite/007_v0200_notification_prefs.sql`
|
||
|
||
**Modified files:**
|
||
- `server/notifications/service.go` — preference resolution, email dispatch
|
||
- `server/store/interfaces.go` — `NotificationPreferenceStore`
|
||
- `server/handlers/notifications.go` — preference CRUD endpoints
|
||
- `server/handlers/admin.go` — SMTP config in admin settings
|
||
- `src/js/admin-handlers.js` — notification admin UI
|
||
- `src/js/app.js` — user notification preferences in settings modal
|
||
|
||
### Checklist (Phase 3)
|
||
|
||
- [x] Migration: `notification_preferences` table (both dialects)
|
||
- [x] Store: `NotificationPreferenceStore` interface + implementations
|
||
- [x] Handlers: preference CRUD endpoints (GET/PUT per type)
|
||
- [x] Notification service: preference resolution chain
|
||
- [x] Email transport: SMTP with TLS, template rendering
|
||
- [x] Email templates: HTML + plaintext for each notification type
|
||
- [x] Admin UI: SMTP config + test email button
|
||
- [x] User UI: notification preferences in Settings
|
||
- [ ] Optional: digest mode (background goroutine + batched email) — deferred
|
||
- [x] Tests: preference resolution chain
|
||
- [x] Tests: email transport (mock SMTP)
|
||
|
||
---
|
||
|
||
## Cross-cutting Concerns
|
||
|
||
### Retention / Cleanup
|
||
|
||
Notifications accumulate. Add a cleanup policy:
|
||
|
||
- Default: retain 90 days, configurable via admin settings (`notifications.retention_days`).
|
||
- Cleanup: background goroutine (daily) deletes `WHERE created_at < NOW() - retention`.
|
||
- Same pattern as compaction scanner — register in `server/main.go` startup.
|
||
|
||
### Notification Count Performance
|
||
|
||
The unread badge polls on app init, then stays current via WebSocket. No periodic polling. Badge syncs across tabs via `notification.read` event (one tab marks read → other tabs update badge).
|
||
|
||
### SQLite Considerations
|
||
|
||
- No `LISTEN/NOTIFY` — WebSocket push uses `Hub.SendToUser()` directly (already in-process, no PG dependency).
|
||
- `datetime('now')` for `created_at` (standard SQLite pattern).
|
||
- `INTEGER` for booleans (standard).
|
||
|
||
### Mobile
|
||
|
||
- Notification bell fits in mobile header (replace one of the existing icons or use hamburger menu).
|
||
- Notification dropdown becomes full-screen overlay on mobile (same pattern as other dropdowns).
|
||
- @mention autocomplete: same touch-friendly dropdown pattern as wikilink autocomplete.
|
||
|
||
### Security
|
||
|
||
- Notifications are user-scoped. No cross-user access via API (every query includes `user_id` from auth context).
|
||
- SMTP password encrypted at rest via vault (same pattern as API keys in v0.9.4).
|
||
- Email transport validates `smtp_host` against SSRF allowlist (same pattern as `url_fetch`).
|
||
- @mention parsing sanitized — no injection via display names (names are alphanumeric + hyphens only, validated on `channel_models` write).
|
||
|
||
---
|
||
|
||
## Dependency Map
|
||
|
||
```
|
||
Phase 1: Notifications Core
|
||
├── notifications table (migration)
|
||
├── NotificationStore (postgres + sqlite)
|
||
├── Notification Service (create + dispatch)
|
||
├── EventBus route (notification.new)
|
||
├── API endpoints (5)
|
||
├── Frontend: bell + dropdown + panel
|
||
└── Wire: KB, groups, resolver → service
|
||
|
||
Phase 2: @mention + Multi-model ← can start in parallel with Phase 1
|
||
├── mentions/parser.go
|
||
├── channel_models CRUD endpoints ← table already exists
|
||
├── completion handler fan-out
|
||
├── Frontend: model pills + autocomplete
|
||
└── Frontend: multi-stream rendering
|
||
|
||
Phase 3: Email + Preferences ← depends on Phase 1
|
||
├── notification_preferences table (migration)
|
||
├── SMTP transport
|
||
├── Email templates
|
||
├── Preference resolution in Service
|
||
└── Admin + User settings UI
|
||
```
|
||
|
||
Phases 1 and 2 have no code-level dependency on each other — they touch different files and can be developed in parallel or in either order. Phase 3 strictly depends on Phase 1 (extends the notification service).
|
||
|
||
---
|
||
|
||
## Version Bump
|
||
|
||
- `VERSION`: `0.19.2` → `0.20.0` after Phase 2 merge (or after Phase 1 if shipping incrementally as `0.20.0-rc1`).
|
||
- Changelog entry after each phase merge.
|
||
- No breaking API changes — all additions.
|
||
|
||
---
|
||
|
||
## What This Unblocks
|
||
|
||
- **v0.23.0 Multi-Participant Channels:** Notifications become the backbone for participant join/leave, new message alerts, and assignment queue events.
|
||
- **v0.25.0 Workflow Engine:** Stage transition notifications, assignment notifications, completion webhooks all route through the notification service.
|
||
- **v0.22.0 Smart Routing:** Multi-model channels demonstrate the fan-out pattern that routing policies will generalize.
|