Changeset 0.28.2 (#183)

This commit is contained in:
2026-03-13 12:19:55 +00:00
parent 9b9e2eb756
commit f94243fbf3
13 changed files with 1070 additions and 21 deletions

View File

@@ -1 +1 @@
0.28.0 0.28.2

View File

@@ -193,9 +193,25 @@ All enum values used across the API. Definitive source of truth.
## Notification Types ## Notification Types
`role.fallback`, `memory.extracted`, `system.announcement`, Convention: `domain.action`. Free-form strings — new types don't
`workflow.assigned`, `workflow.claimed`, `task.completed`, require migration.
`task.failed`, `user.mentioned`
| Type | Description |
|------|-------------|
| `role.fallback` | Model role fell back to secondary provider |
| `kb.ready` | Knowledge base finished indexing |
| `kb.error` | Knowledge base indexing failed |
| `grant.changed` | User added to or removed from a group |
| `memory.extracted` | New memories extracted from conversation |
| `user.mentioned` | User was @mentioned in a channel |
| `workflow.assigned` | Workflow stage assigned to team/user |
| `workflow.claimed` | Workflow assignment claimed by a user |
| `task.completed` | Scheduled task finished successfully |
| `task.failed` | Scheduled task failed |
| `task.budget_exceeded` | Task hit token/tool/wall-clock budget |
**Planned (not yet implemented):**
`system.announcement` (v0.28.4 — admin broadcast to all users)
## Git Auth Types ## Git Auth Types

View File

@@ -3,6 +3,9 @@
Real-time notification infrastructure with WebSocket delivery and Real-time notification infrastructure with WebSocket delivery and
optional email transport. optional email transport.
**Auth:** All endpoints require authentication (JWT via `sb_token`
cookie or `Authorization: Bearer` header).
### CRUD ### CRUD
``` ```
@@ -13,32 +16,102 @@ POST /notifications/mark-all-read → mark all as read
DELETE /notifications/:id DELETE /notifications/:id
``` ```
**`GET /notifications`** — paginated list for the authenticated user.
Query parameters:
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `limit` | int | 20 | 1100, clamped |
| `offset` | int | 0 | Pagination offset |
| `unread_only` | bool | false | Filter to unread only |
Response:
```json
{
"data": [ ... ],
"total": 42,
"limit": 20,
"offset": 0
}
```
Notification object: Notification object:
```json ```json
{ {
"id": "uuid", "id": "uuid",
"user_id": "uuid", "user_id": "uuid",
"type": "role.fallback|memory.extracted|system.announcement|...", "type": "role.fallback",
"title": "Role Fallback Triggered", "title": "Role Fallback Triggered",
"body": "Primary model unavailable, using fallback", "body": "Primary model unavailable, using fallback",
"metadata": {}, "resource_type": "channel",
"read": false, "resource_id": "uuid",
"created_at": "..." "is_read": false,
"created_at": "2025-01-15T12:00:00Z"
} }
``` ```
`resource_type` and `resource_id` are optional — used for
click-to-navigate. The resource may be deleted (no FK constraint).
### Preferences ### Preferences
Users control per-type delivery: Users control per-type delivery:
``` ```
GET /notifications/preferences → { "preferences": [...] } GET /notifications/preferences → { "data": [...] }
PUT /notifications/preferences/:type ← { "in_app": true, "email": false } PUT /notifications/preferences/:type ← { "in_app": true, "email": false }
DELETE /notifications/preferences/:type → reset to default DELETE /notifications/preferences/:type → reset to default
``` ```
**`GET /notifications/preferences`** returns all preferences set by
the user. Empty array if none are configured.
**`PUT /notifications/preferences/:type`** creates or updates a
preference. Both `in_app` and `email` fields are optional (merges
with existing values if set).
**`DELETE /notifications/preferences/:type`** removes the preference,
falling back to the next level in the resolution chain. Idempotent —
deleting a non-existent preference returns 200.
Resolution: specific type pref → user wildcard `*` pref → system Resolution: specific type pref → user wildcard `*` pref → system
default (`in_app=true`, `email=false`). default (`in_app=true`, `email=false`).
Returns 503 if the preference store is not available (unmanaged mode).
### Admin
```
POST /admin/notifications/test-email → send test email to requesting admin
```
Requires admin role. Loads SMTP config from platform settings,
sends a test message to the admin's registered email address.
### WebSocket Events
| Event | Payload | Description |
|-------|---------|-------------|
| `notification.new` | Full notification object | New notification created |
| `notification.read` | `{"id": "uuid"}` | Single notification marked read |
| `notification.read` | `{"action": "mark_all_read"}` | All notifications marked read |
These are targeted via `Hub.SendToUser` — no room subscription
required. Used for badge sync across tabs.
### Notification Types
See [enums.md](enums.md#notification-types) for the canonical list.
Types are free-form strings (`domain.action` convention) — new types
don't require migration.
### Retention
Background cleanup prunes notifications older than 90 days (default,
configurable via `WithRetention`). Runs daily after a 5-minute
startup delay.
--- ---

View File

@@ -63,6 +63,11 @@ Clients subscribe to rooms for scoped event delivery:
| `memory.extracted` | → client | New memory extracted | | `memory.extracted` | → client | New memory extracted |
| `memory.status` | → client | Memory approved/rejected | | `memory.status` | → client | Memory approved/rejected |
| `role.fallback` | → client | Role fallback triggered | | `role.fallback` | → client | Role fallback triggered |
| `user.mentioned` | → client | User was @mentioned in a channel |
| `workflow.assigned` | → client | Workflow stage assigned to team/user |
| `workflow.claimed` | → client | Workflow assignment claimed |
| `workflow.advanced` | → client | Workflow stage advanced |
| `workflow.completed` | → client | Workflow instance completed |
| `workspace.updated` | → client | Workspace state changed | | `workspace.updated` | → client | Workspace state changed |
| `project.updated` | → client | Project metadata changed | | `project.updated` | → client | Project metadata changed |
| `extension.updated` | → client | Extension config changed | | `extension.updated` | → client | Extension config changed |
@@ -168,8 +173,12 @@ only, `↔ both` = bidirectional.
### Notification Types ### Notification Types
`role.fallback`, `memory.extracted`, `system.announcement`, plus `role.fallback`, `kb.ready`, `kb.error`, `grant.changed`,
extensible via notification service. `memory.extracted`, `user.mentioned`, `workflow.assigned`,
`workflow.claimed`, `task.completed`, `task.failed`,
`task.budget_exceeded`
See [enums.md](enums.md#notification-types) for full descriptions.
### Git Auth Types ### Git Auth Types

View File

@@ -69,12 +69,20 @@ Audit arc, frontend SDK, and infrastructure improvements.
### v0.28.2 — ICD Audit: Notifications + Profile + Knowledge + Notes + Providers ### v0.28.2 — ICD Audit: Notifications + Profile + Knowledge + Notes + Providers
Known ICD report failures to investigate (likely envelope shape mismatches): Known ICD report failures to investigate (likely envelope shape mismatches):
- [ ] `GET /settings``missing key "settings"` (profile) - [ ] `GET /settings``missing key "settings"` (profile)
- [ ] `GET /notifications/preferences``missing key "preferences"` - [x] `GET /notifications/preferences``missing key "preferences"` (envelope fix)
- [ ] `GET /workspaces``missing key "data"` - [ ] `GET /workspaces``missing key "data"`
- [ ] `GET /git-credentials``missing key "data"` - [ ] `GET /git-credentials``missing key "data"`
- [ ] Notes, providers — passed clean but need full trace (route → handler → store → tests) - [ ] Notes, providers — passed clean but need full trace (route → handler → store → tests)
- [ ] WebSocket event contract audit: document `message.created`, `workflow.advanced`, - [ ] WebSocket event contract audit: document `message.created`, `workflow.advanced`,
`presence.changed`, `typing`, `workflow.assigned` event shapes in ICD `presence.changed`, `typing`, `workflow.assigned` event shapes in ICD
- [x] Notifications ICD audit: object shape, query params, response envelopes, WS events
- [x] Notification type enum sync: remove aspirational types, add implemented types
(`kb.ready`, `kb.error`, `grant.changed`, `task.budget_exceeded`)
- [x] Implement `memory.extracted` notification (hook in memory extractor)
- [x] Implement `user.mentioned` persisted notification (was WS-only)
- [x] Implement `workflow.claimed` persisted notification (was WS-only)
- [x] Remove dead `NotifTypeProjectInvite` constant
- [ ] Notification handler + store tests (PG + SQLite)
### v0.28.3 — Security Tier (ICD Runner Red Team) ### v0.28.3 — Security Tier (ICD Runner Red Team)
New `security` tier in ICD test runner. Multi-user fixtures already exist. New `security` tier in ICD test runner. Multi-user fixtures already exist.
@@ -115,6 +123,9 @@ New `security` tier in ICD test runner. Multi-user fixtures already exist.
- [ ] Git credentials settings UI: vault-encrypted per-user credentials, SSH key - [ ] Git credentials settings UI: vault-encrypted per-user credentials, SSH key
management, per-workspace remote config. Table exists (`git_credentials`), vault management, per-workspace remote config. Table exists (`git_credentials`), vault
pattern exists — needs settings section and CRUD handler. pattern exists — needs settings section and CRUD handler.
- [ ] `system.announcement` notification type: admin broadcast endpoint
(`POST /admin/notifications/broadcast`), fan-out to all active users via
`NotifyMany`, admin UI for composing announcements
### v0.28.5 — Frontend SDK ### v0.28.5 — Frontend SDK
`switchboard-sdk.js` — composition layer over existing globals. Surface `switchboard-sdk.js` — composition layer over existing globals. Surface

View File

@@ -21,6 +21,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/knowledge" "git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/routing" "git.gobha.me/xcaliber/chat-switchboard/routing"
"git.gobha.me/xcaliber/chat-switchboard/storage" "git.gobha.me/xcaliber/chat-switchboard/storage"
@@ -584,6 +585,12 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
Ts: time.Now().UnixMilli(), Ts: time.Now().UnixMilli(),
}) })
} }
// Persist notification for bell/inbox (v0.28.2)
if svc := notifications.Default(); svc != nil {
notifications.NotifyUserMentioned(svc, mentionedUserID, channelID, userID, truncateContent(req.Content, 120))
}
c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID}) c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID})
return return
} }

View File

@@ -0,0 +1,558 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
)
// ── Notification Test Harness ──────────────
type notifHarness struct {
*testHarness
stores store.Stores
adminToken string
adminID string
userToken string
userID string
}
func setupNotifHarness(t *testing.T) *notifHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg))
notifH := NewNotificationHandler(stores, nil) // no hub in tests
protected.GET("/notifications", notifH.List)
protected.GET("/notifications/unread-count", notifH.UnreadCount)
protected.PATCH("/notifications/:id/read", notifH.MarkRead)
protected.POST("/notifications/mark-all-read", notifH.MarkAllRead)
protected.DELETE("/notifications/:id", notifH.Delete)
protected.GET("/notifications/preferences", notifH.ListPreferences)
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
// Seed admin
adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID)
adminToken := makeToken(adminID, "notif-admin@test.com", "admin")
// Seed regular user
userID := database.SeedTestUser(t, "notif-user", "notif-user@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "notif-user@test.com", "user")
return &notifHarness{
testHarness: &testHarness{router: r, t: t},
stores: stores,
adminToken: adminToken,
adminID: adminID,
userToken: userToken,
userID: userID,
}
}
// seedNotification inserts a notification directly via the store.
func (h *notifHarness) seedNotification(userID, notifType, title string) string {
h.t.Helper()
n := &models.Notification{
UserID: userID,
Type: notifType,
Title: title,
}
if err := h.stores.Notifications.Create(context.Background(), n); err != nil {
h.t.Fatalf("seedNotification: %v", err)
}
return n.ID
}
// ── GET /notifications ─────────────────────
func TestNotifications_List_Empty(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("GET", "/api/v1/notifications", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body struct {
Data []json.RawMessage `json:"data"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if body.Data == nil {
t.Error("data should be [] not null")
}
if len(body.Data) != 0 {
t.Errorf("expected 0 items, got %d", len(body.Data))
}
if body.Total != 0 {
t.Errorf("total: got %d, want 0", body.Total)
}
if body.Limit != 20 {
t.Errorf("default limit: got %d, want 20", body.Limit)
}
}
func TestNotifications_List_Pagination(t *testing.T) {
h := setupNotifHarness(t)
// Seed 5 notifications
for i := 0; i < 5; i++ {
h.seedNotification(h.userID, "test.type", "Test Notification")
}
resp := h.request("GET", "/api/v1/notifications?limit=2&offset=0", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d", resp.Code)
}
var body struct {
Data []json.RawMessage `json:"data"`
Total int `json:"total"`
Limit int `json:"limit"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if len(body.Data) != 2 {
t.Errorf("items: got %d, want 2", len(body.Data))
}
if body.Total != 5 {
t.Errorf("total: got %d, want 5", body.Total)
}
if body.Limit != 2 {
t.Errorf("limit: got %d, want 2", body.Limit)
}
}
func TestNotifications_List_UnreadOnly(t *testing.T) {
h := setupNotifHarness(t)
id1 := h.seedNotification(h.userID, "test.type", "Unread One")
h.seedNotification(h.userID, "test.type", "Unread Two")
_ = id1
// Mark one as read
h.stores.Notifications.MarkRead(context.Background(), id1, h.userID)
resp := h.request("GET", "/api/v1/notifications?unread_only=true", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d", resp.Code)
}
var body struct {
Data []json.RawMessage `json:"data"`
Total int `json:"total"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if len(body.Data) != 1 {
t.Errorf("unread items: got %d, want 1", len(body.Data))
}
if body.Total != 1 {
t.Errorf("total unread: got %d, want 1", body.Total)
}
}
func TestNotifications_List_UserIsolation(t *testing.T) {
h := setupNotifHarness(t)
// Seed notifications for admin, not user
h.seedNotification(h.adminID, "test.type", "Admin Only")
resp := h.request("GET", "/api/v1/notifications", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d", resp.Code)
}
var body struct {
Data []json.RawMessage `json:"data"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if len(body.Data) != 0 {
t.Errorf("user should not see admin's notifications, got %d", len(body.Data))
}
}
// ── GET /notifications/unread-count ────────
func TestNotifications_UnreadCount(t *testing.T) {
h := setupNotifHarness(t)
h.seedNotification(h.userID, "test.type", "N1")
id2 := h.seedNotification(h.userID, "test.type", "N2")
h.seedNotification(h.userID, "test.type", "N3")
// Mark one as read
h.stores.Notifications.MarkRead(context.Background(), id2, h.userID)
resp := h.request("GET", "/api/v1/notifications/unread-count", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d", resp.Code)
}
var body struct {
Count int `json:"count"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if body.Count != 2 {
t.Errorf("unread count: got %d, want 2", body.Count)
}
}
// ── PATCH /notifications/:id/read ─────────
func TestNotifications_MarkRead(t *testing.T) {
h := setupNotifHarness(t)
id := h.seedNotification(h.userID, "test.type", "Read Me")
resp := h.request("PATCH", "/api/v1/notifications/"+id+"/read", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
// Verify via unread-count
resp = h.request("GET", "/api/v1/notifications/unread-count", h.userToken, nil)
var body struct {
Count int `json:"count"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if body.Count != 0 {
t.Errorf("unread count after mark read: got %d, want 0", body.Count)
}
}
func TestNotifications_MarkRead_WrongUser(t *testing.T) {
h := setupNotifHarness(t)
// Notification belongs to admin
id := h.seedNotification(h.adminID, "test.type", "Admin's")
// User tries to mark it read
resp := h.request("PATCH", "/api/v1/notifications/"+id+"/read", h.userToken, nil)
if resp.Code != http.StatusNotFound {
t.Errorf("cross-user mark read: got %d, want 404", resp.Code)
}
}
func TestNotifications_MarkRead_NotFound(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("PATCH", "/api/v1/notifications/00000000-0000-0000-0000-000000000000/read", h.userToken, nil)
if resp.Code != http.StatusNotFound {
t.Errorf("non-existent: got %d, want 404", resp.Code)
}
}
// ── POST /notifications/mark-all-read ─────
func TestNotifications_MarkAllRead(t *testing.T) {
h := setupNotifHarness(t)
h.seedNotification(h.userID, "test.type", "N1")
h.seedNotification(h.userID, "test.type", "N2")
h.seedNotification(h.userID, "test.type", "N3")
resp := h.request("POST", "/api/v1/notifications/mark-all-read", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d", resp.Code)
}
// Verify
resp = h.request("GET", "/api/v1/notifications/unread-count", h.userToken, nil)
var body struct {
Count int `json:"count"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if body.Count != 0 {
t.Errorf("unread count after mark-all-read: got %d, want 0", body.Count)
}
}
func TestNotifications_MarkAllRead_IsolatesUsers(t *testing.T) {
h := setupNotifHarness(t)
// Both users have notifications
h.seedNotification(h.userID, "test.type", "User's")
h.seedNotification(h.adminID, "test.type", "Admin's")
// User marks all read
h.request("POST", "/api/v1/notifications/mark-all-read", h.userToken, nil)
// Admin should still have unread
resp := h.request("GET", "/api/v1/notifications/unread-count", h.adminToken, nil)
var body struct {
Count int `json:"count"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if body.Count != 1 {
t.Errorf("admin unread count: got %d, want 1", body.Count)
}
}
// ── DELETE /notifications/:id ─────────────
func TestNotifications_Delete(t *testing.T) {
h := setupNotifHarness(t)
id := h.seedNotification(h.userID, "test.type", "Delete Me")
resp := h.request("DELETE", "/api/v1/notifications/"+id, h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d", resp.Code)
}
// Verify gone
resp = h.request("GET", "/api/v1/notifications", h.userToken, nil)
var body struct {
Data []json.RawMessage `json:"data"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if len(body.Data) != 0 {
t.Errorf("notification should be deleted, got %d", len(body.Data))
}
}
func TestNotifications_Delete_WrongUser(t *testing.T) {
h := setupNotifHarness(t)
id := h.seedNotification(h.adminID, "test.type", "Admin's")
resp := h.request("DELETE", "/api/v1/notifications/"+id, h.userToken, nil)
if resp.Code != http.StatusNotFound {
t.Errorf("cross-user delete: got %d, want 404", resp.Code)
}
}
func TestNotifications_Delete_NotFound(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("DELETE", "/api/v1/notifications/00000000-0000-0000-0000-000000000000", h.userToken, nil)
if resp.Code != http.StatusNotFound {
t.Errorf("non-existent: got %d, want 404", resp.Code)
}
}
// ── GET /notifications/preferences ────────
func TestNotifications_Preferences_Empty(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body struct {
Data []json.RawMessage `json:"data"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v, raw: %s", err, resp.Body.String())
}
if body.Data == nil {
t.Error("data should be [] not null")
}
if len(body.Data) != 0 {
t.Errorf("expected empty prefs, got %d", len(body.Data))
}
}
func TestNotifications_Preferences_Envelope(t *testing.T) {
h := setupNotifHarness(t)
// Verify response is wrapped in {"data": []}
resp := h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil)
var raw map[string]json.RawMessage
json.Unmarshal(resp.Body.Bytes(), &raw)
if _, ok := raw["data"]; !ok {
t.Errorf("response must have 'data' key, got keys: %v, raw: %s",
keys(raw), resp.Body.String())
}
}
// ── PUT /notifications/preferences/:type ──
func TestNotifications_SetPreference(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{
"in_app": true,
"email": true,
})
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var pref models.NotificationPreference
json.Unmarshal(resp.Body.Bytes(), &pref)
if !pref.InApp || !pref.Email {
t.Errorf("expected in_app=true email=true, got %+v", pref)
}
// Verify via list
resp = h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil)
var body struct {
Data []models.NotificationPreference `json:"data"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if len(body.Data) != 1 {
t.Fatalf("prefs count: got %d, want 1", len(body.Data))
}
if body.Data[0].Type != "kb.ready" {
t.Errorf("type: got %q, want %q", body.Data[0].Type, "kb.ready")
}
}
func TestNotifications_SetPreference_PartialUpdate(t *testing.T) {
h := setupNotifHarness(t)
// Set both
h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{
"in_app": true,
"email": true,
})
// Partial update: only change email
resp := h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{
"email": false,
})
if resp.Code != http.StatusOK {
t.Fatalf("partial update: got %d", resp.Code)
}
var pref models.NotificationPreference
json.Unmarshal(resp.Body.Bytes(), &pref)
if !pref.InApp {
t.Error("in_app should remain true after partial update")
}
if pref.Email {
t.Error("email should be false after partial update")
}
}
func TestNotifications_SetPreference_Wildcard(t *testing.T) {
h := setupNotifHarness(t)
resp := h.request("PUT", "/api/v1/notifications/preferences/*", h.userToken, map[string]interface{}{
"in_app": false,
"email": true,
})
if resp.Code != http.StatusOK {
t.Fatalf("wildcard pref: got %d, body: %s", resp.Code, resp.Body.String())
}
var pref models.NotificationPreference
json.Unmarshal(resp.Body.Bytes(), &pref)
if pref.Type != "*" {
t.Errorf("type: got %q, want %q", pref.Type, "*")
}
}
// ── DELETE /notifications/preferences/:type ──
func TestNotifications_DeletePreference(t *testing.T) {
h := setupNotifHarness(t)
// Create then delete
h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{
"email": true,
})
resp := h.request("DELETE", "/api/v1/notifications/preferences/kb.ready", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("delete pref: got %d", resp.Code)
}
// Verify gone
resp = h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil)
var body struct {
Data []models.NotificationPreference `json:"data"`
}
json.Unmarshal(resp.Body.Bytes(), &body)
if len(body.Data) != 0 {
t.Errorf("prefs should be empty after delete, got %d", len(body.Data))
}
}
func TestNotifications_DeletePreference_Idempotent(t *testing.T) {
h := setupNotifHarness(t)
// Delete non-existent preference should still return 200 (idempotent)
resp := h.request("DELETE", "/api/v1/notifications/preferences/nonexistent.type", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Errorf("idempotent delete: got %d, want 200", resp.Code)
}
}
// ── Auth enforcement ──────────────────────
func TestNotifications_Unauthenticated(t *testing.T) {
h := setupNotifHarness(t)
endpoints := []struct {
method string
path string
}{
{"GET", "/api/v1/notifications"},
{"GET", "/api/v1/notifications/unread-count"},
{"PATCH", "/api/v1/notifications/fake-id/read"},
{"POST", "/api/v1/notifications/mark-all-read"},
{"DELETE", "/api/v1/notifications/fake-id"},
{"GET", "/api/v1/notifications/preferences"},
{"PUT", "/api/v1/notifications/preferences/test"},
{"DELETE", "/api/v1/notifications/preferences/test"},
}
for _, e := range endpoints {
resp := h.request(e.method, e.path, "", nil)
if resp.Code != http.StatusUnauthorized {
t.Errorf("%s %s without token: got %d, want 401", e.method, e.path, resp.Code)
}
}
}
// ── helpers ────────────────────────────────
func keys(m map[string]json.RawMessage) []string {
var ks []string
for k := range m {
ks = append(ks, k)
}
return ks
}

View File

@@ -184,7 +184,7 @@ func (h *NotificationHandler) ListPreferences(c *gin.Context) {
} }
if h.stores.NotifPrefs == nil { if h.stores.NotifPrefs == nil {
c.JSON(http.StatusOK, []models.NotificationPreference{}) c.JSON(http.StatusOK, gin.H{"data": []models.NotificationPreference{}})
return return
} }
@@ -196,7 +196,7 @@ func (h *NotificationHandler) ListPreferences(c *gin.Context) {
if prefs == nil { if prefs == nil {
prefs = []models.NotificationPreference{} prefs = []models.NotificationPreference{}
} }
c.JSON(http.StatusOK, prefs) c.JSON(http.StatusOK, gin.H{"data": prefs})
} }
// PUT /api/v1/notifications/preferences/:type // PUT /api/v1/notifications/preferences/:type

View File

@@ -9,6 +9,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
) )
// ── Workflow Assignment Handler ───────────── // ── Workflow Assignment Handler ─────────────
@@ -143,6 +144,15 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
}) })
} }
// Persist notification for bell/inbox (v0.28.2)
if svc := notifications.Default(); svc != nil {
var channelID string
_ = database.DB.QueryRowContext(c.Request.Context(),
database.Q(`SELECT channel_id FROM workflow_assignments WHERE id = $1`),
assignmentID).Scan(&channelID)
notifications.NotifyWorkflowClaimed(svc, userID, assignmentID, channelID)
}
c.JSON(http.StatusOK, gin.H{"claimed": true, "assignment_id": assignmentID}) c.JSON(http.StatusOK, gin.H{"claimed": true, "assignment_id": assignmentID})
} }

View File

@@ -10,6 +10,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/knowledge" "git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles" "git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
@@ -218,6 +219,11 @@ func (e *Extractor) Extract(ctx context.Context, channelID, userID, teamID, pers
if saved > 0 { if saved > 0 {
log.Printf("✅ memory extraction: channel %s → %d facts", channelID, saved) log.Printf("✅ memory extraction: channel %s → %d facts", channelID, saved)
// Notify user about extracted memories (v0.28.2)
if svc := notifications.Default(); svc != nil {
notifications.NotifyMemoryExtracted(svc, userID, channelID, saved)
}
} }
return err return err
} }

View File

@@ -23,11 +23,17 @@ type Notification struct {
// Notification type constants. Convention: domain.action. // Notification type constants. Convention: domain.action.
// Free-form strings — new types don't require migration. // Free-form strings — new types don't require migration.
const ( const (
NotifTypeRoleFallback = "role.fallback" NotifTypeRoleFallback = "role.fallback"
NotifTypeKBReady = "kb.ready" NotifTypeKBReady = "kb.ready"
NotifTypeKBError = "kb.error" NotifTypeKBError = "kb.error"
NotifTypeGrantChanged = "grant.changed" NotifTypeGrantChanged = "grant.changed"
NotifTypeProjectInvite = "project.invite" NotifTypeMemoryExtracted = "memory.extracted"
NotifTypeUserMentioned = "user.mentioned"
NotifTypeWorkflowAssign = "workflow.assigned"
NotifTypeWorkflowClaimed = "workflow.claimed"
NotifTypeTaskCompleted = "task.completed"
NotifTypeTaskFailed = "task.failed"
NotifTypeTaskBudget = "task.budget_exceeded"
) )
// Resource type constants for click-to-navigate. // Resource type constants for click-to-navigate.

View File

@@ -144,3 +144,68 @@ func NotifyGroupMemberRemoved(svc *Service, userID, groupID, groupName string) {
log.Printf("[notifications] grant.changed (remove) failed for user %s: %v", userID, err) log.Printf("[notifications] grant.changed (remove) failed for user %s: %v", userID, err)
} }
} }
// ── Memory Extraction ──────────────────────
// Called from memory/extractor.go after successful extraction.
// NotifyMemoryExtracted creates a notification when new memories are extracted.
func NotifyMemoryExtracted(svc *Service, userID, channelID string, count int) {
if svc == nil {
return
}
title := fmt.Sprintf("%d new memories extracted", count)
if count == 1 {
title = "1 new memory extracted"
}
n := models.Notification{
UserID: userID,
Type: models.NotifTypeMemoryExtracted,
Title: title,
ResourceType: models.ResourceTypeChannel,
ResourceID: channelID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] memory.extracted failed for user %s: %v", userID, err)
}
}
// ── User Mention ───────────────────────────
// Called from handlers/completion.go when an @mention targets a human user.
// NotifyUserMentioned creates a persisted notification for an @mention.
func NotifyUserMentioned(svc *Service, mentionedUserID, channelID, fromUserID, preview string) {
if svc == nil {
return
}
n := models.Notification{
UserID: mentionedUserID,
Type: models.NotifTypeUserMentioned,
Title: "You were mentioned in a conversation",
Body: preview,
ResourceType: models.ResourceTypeChannel,
ResourceID: channelID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] user.mentioned failed for user %s: %v", mentionedUserID, err)
}
}
// ── Workflow Claimed ───────────────────────
// Called from handlers/workflow_assignments.go when a user claims an assignment.
// NotifyWorkflowClaimed creates a persisted notification when an assignment is claimed.
func NotifyWorkflowClaimed(svc *Service, claimerID, assignmentID, channelID string) {
if svc == nil {
return
}
n := models.Notification{
UserID: claimerID,
Type: models.NotifTypeWorkflowClaimed,
Title: "You claimed a workflow assignment",
ResourceType: models.ResourceTypeChannel,
ResourceID: channelID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] workflow.claimed failed for user %s: %v", claimerID, err)
}
}

View File

@@ -0,0 +1,288 @@
package notifications
import (
"context"
"testing"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// mockNotifStore implements store.NotificationStore for testing.
type mockNotifStore struct {
items []models.Notification
}
func (m *mockNotifStore) Create(_ context.Context, n *models.Notification) error {
n.ID = "mock-id"
n.CreatedAt = time.Now()
m.items = append(m.items, *n)
return nil
}
func (m *mockNotifStore) ListByUser(_ context.Context, _ string, _, _ int, _ bool) ([]models.Notification, int, error) {
return m.items, len(m.items), nil
}
func (m *mockNotifStore) MarkRead(_ context.Context, _, _ string) error { return nil }
func (m *mockNotifStore) MarkAllRead(_ context.Context, _ string) error { return nil }
func (m *mockNotifStore) Delete(_ context.Context, _, _ string) error { return nil }
func (m *mockNotifStore) UnreadCount(_ context.Context, _ string) (int, error) {
return len(m.items), nil
}
func (m *mockNotifStore) DeleteOlderThan(_ context.Context, _ time.Time) (int64, error) {
return 0, nil
}
// newTestService creates a Service with a mock store for testing sources.
func newTestService() (*Service, *mockNotifStore) {
ms := &mockNotifStore{}
svc := NewService(ms, nil) // no hub
return svc, ms
}
// ── NotifyKBReady ─────────────────────────
func TestNotifyKBReady(t *testing.T) {
svc, ms := newTestService()
NotifyKBReady(svc, "user1", "kb1", "My KB", 42)
if len(ms.items) != 1 {
t.Fatalf("expected 1 notification, got %d", len(ms.items))
}
n := ms.items[0]
if n.UserID != "user1" {
t.Errorf("user_id: got %q, want %q", n.UserID, "user1")
}
if n.Type != models.NotifTypeKBReady {
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeKBReady)
}
if n.ResourceType != models.ResourceTypeKnowledgeBase {
t.Errorf("resource_type: got %q, want %q", n.ResourceType, models.ResourceTypeKnowledgeBase)
}
if n.ResourceID != "kb1" {
t.Errorf("resource_id: got %q, want %q", n.ResourceID, "kb1")
}
}
func TestNotifyKBReady_NilService(t *testing.T) {
// Should not panic
NotifyKBReady(nil, "user1", "kb1", "My KB", 10)
}
// ── NotifyKBError ─────────────────────────
func TestNotifyKBError(t *testing.T) {
svc, ms := newTestService()
NotifyKBError(svc, "user1", "kb2", "Bad KB", "chunk failed")
if len(ms.items) != 1 {
t.Fatalf("expected 1 notification, got %d", len(ms.items))
}
n := ms.items[0]
if n.Type != models.NotifTypeKBError {
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeKBError)
}
if n.Body != "chunk failed" {
t.Errorf("body: got %q, want %q", n.Body, "chunk failed")
}
}
// ── NotifyGroupMemberAdded ────────────────
func TestNotifyGroupMemberAdded(t *testing.T) {
svc, ms := newTestService()
NotifyGroupMemberAdded(svc, "user1", "grp1", "Engineering")
if len(ms.items) != 1 {
t.Fatalf("expected 1 notification, got %d", len(ms.items))
}
n := ms.items[0]
if n.Type != models.NotifTypeGrantChanged {
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeGrantChanged)
}
if n.ResourceType != models.ResourceTypeGroup {
t.Errorf("resource_type: got %q, want %q", n.ResourceType, models.ResourceTypeGroup)
}
if n.ResourceID != "grp1" {
t.Errorf("resource_id: got %q, want %q", n.ResourceID, "grp1")
}
}
// ── NotifyGroupMemberRemoved ──────────────
func TestNotifyGroupMemberRemoved(t *testing.T) {
svc, ms := newTestService()
NotifyGroupMemberRemoved(svc, "user1", "grp1", "Engineering")
if len(ms.items) != 1 {
t.Fatalf("expected 1 notification, got %d", len(ms.items))
}
n := ms.items[0]
if n.Type != models.NotifTypeGrantChanged {
t.Errorf("type: got %q", n.Type)
}
}
// ── NotifyMemoryExtracted ─────────────────
func TestNotifyMemoryExtracted(t *testing.T) {
svc, ms := newTestService()
NotifyMemoryExtracted(svc, "user1", "chan1", 5)
if len(ms.items) != 1 {
t.Fatalf("expected 1 notification, got %d", len(ms.items))
}
n := ms.items[0]
if n.Type != models.NotifTypeMemoryExtracted {
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeMemoryExtracted)
}
if n.Title != "5 new memories extracted" {
t.Errorf("title: got %q", n.Title)
}
if n.ResourceType != models.ResourceTypeChannel {
t.Errorf("resource_type: got %q", n.ResourceType)
}
if n.ResourceID != "chan1" {
t.Errorf("resource_id: got %q", n.ResourceID)
}
}
func TestNotifyMemoryExtracted_Singular(t *testing.T) {
svc, ms := newTestService()
NotifyMemoryExtracted(svc, "user1", "chan1", 1)
if len(ms.items) != 1 {
t.Fatalf("expected 1 notification, got %d", len(ms.items))
}
if ms.items[0].Title != "1 new memory extracted" {
t.Errorf("singular title: got %q", ms.items[0].Title)
}
}
func TestNotifyMemoryExtracted_NilService(t *testing.T) {
NotifyMemoryExtracted(nil, "user1", "chan1", 3) // should not panic
}
// ── NotifyUserMentioned ───────────────────
func TestNotifyUserMentioned(t *testing.T) {
svc, ms := newTestService()
NotifyUserMentioned(svc, "mentioned-user", "chan1", "from-user", "hey @you check this")
if len(ms.items) != 1 {
t.Fatalf("expected 1 notification, got %d", len(ms.items))
}
n := ms.items[0]
if n.UserID != "mentioned-user" {
t.Errorf("user_id: got %q", n.UserID)
}
if n.Type != models.NotifTypeUserMentioned {
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeUserMentioned)
}
if n.Body != "hey @you check this" {
t.Errorf("body: got %q", n.Body)
}
if n.ResourceType != models.ResourceTypeChannel {
t.Errorf("resource_type: got %q", n.ResourceType)
}
if n.ResourceID != "chan1" {
t.Errorf("resource_id: got %q", n.ResourceID)
}
}
func TestNotifyUserMentioned_NilService(t *testing.T) {
NotifyUserMentioned(nil, "u1", "c1", "u2", "hello") // should not panic
}
// ── NotifyWorkflowClaimed ─────────────────
func TestNotifyWorkflowClaimed(t *testing.T) {
svc, ms := newTestService()
NotifyWorkflowClaimed(svc, "claimer1", "assign1", "chan1")
if len(ms.items) != 1 {
t.Fatalf("expected 1 notification, got %d", len(ms.items))
}
n := ms.items[0]
if n.UserID != "claimer1" {
t.Errorf("user_id: got %q", n.UserID)
}
if n.Type != models.NotifTypeWorkflowClaimed {
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeWorkflowClaimed)
}
if n.ResourceType != models.ResourceTypeChannel {
t.Errorf("resource_type: got %q", n.ResourceType)
}
if n.ResourceID != "chan1" {
t.Errorf("resource_id: got %q", n.ResourceID)
}
}
func TestNotifyWorkflowClaimed_NilService(t *testing.T) {
NotifyWorkflowClaimed(nil, "u1", "a1", "c1") // should not panic
}
// ── NotifyMany ────────────────────────────
func TestNotifyMany(t *testing.T) {
svc, ms := newTestService()
template := models.Notification{
Type: "test.broadcast",
Title: "Hello everyone",
}
svc.NotifyMany(context.Background(), []string{"u1", "u2", "u3"}, template)
if len(ms.items) != 3 {
t.Fatalf("expected 3 notifications, got %d", len(ms.items))
}
seen := map[string]bool{}
for _, n := range ms.items {
seen[n.UserID] = true
if n.Type != "test.broadcast" {
t.Errorf("type: got %q", n.Type)
}
}
for _, uid := range []string{"u1", "u2", "u3"} {
if !seen[uid] {
t.Errorf("missing notification for %s", uid)
}
}
}
// ── Email subject lines for new types ─────
func TestSubjectForNotification_NewTypes(t *testing.T) {
tests := []struct {
notifType string
title string
wantPfx string
}{
{"memory.extracted", "3 new memories", "[Test] "},
{"user.mentioned", "You were mentioned", "[Test] "},
{"workflow.claimed", "Assignment claimed", "[Test] "},
{"task.completed", "Task done", "[Test] "},
{"task.budget_exceeded", "Budget hit", "[Test] "},
}
for _, tt := range tests {
n := &models.Notification{Type: tt.notifType, Title: tt.title}
got := SubjectForNotification(n, "Test")
if got == "" {
t.Errorf("SubjectForNotification(%q) returned empty", tt.notifType)
}
// All should at least have the instance prefix
if len(got) < len(tt.wantPfx) {
t.Errorf("SubjectForNotification(%q) too short: %q", tt.notifType, got)
}
}
}