From f94243fbf381b16b0f5cd44b6c055a90eb2369f3 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Fri, 13 Mar 2026 12:19:55 +0000 Subject: [PATCH] Changeset 0.28.2 (#183) --- VERSION | 2 +- docs/ICD/enums.md | 22 +- docs/ICD/notifications.md | 87 +++- docs/ICD/websocket.md | 13 +- docs/ROADMAP.md | 13 +- server/handlers/completion.go | 7 + server/handlers/notification_test.go | 558 ++++++++++++++++++++++++ server/handlers/notifications.go | 4 +- server/handlers/workflow_assignments.go | 10 + server/memory/extractor.go | 6 + server/models/models_notification.go | 16 +- server/notifications/sources.go | 65 +++ server/notifications/sources_test.go | 288 ++++++++++++ 13 files changed, 1070 insertions(+), 21 deletions(-) create mode 100644 server/handlers/notification_test.go create mode 100644 server/notifications/sources_test.go diff --git a/VERSION b/VERSION index 022a033..5fe1ecc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.0 \ No newline at end of file +0.28.2 \ No newline at end of file diff --git a/docs/ICD/enums.md b/docs/ICD/enums.md index 637ce32..5826f10 100644 --- a/docs/ICD/enums.md +++ b/docs/ICD/enums.md @@ -193,9 +193,25 @@ All enum values used across the API. Definitive source of truth. ## Notification Types -`role.fallback`, `memory.extracted`, `system.announcement`, -`workflow.assigned`, `workflow.claimed`, `task.completed`, -`task.failed`, `user.mentioned` +Convention: `domain.action`. Free-form strings — new types don't +require migration. + +| 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 diff --git a/docs/ICD/notifications.md b/docs/ICD/notifications.md index 79a4891..727d3e5 100644 --- a/docs/ICD/notifications.md +++ b/docs/ICD/notifications.md @@ -3,6 +3,9 @@ Real-time notification infrastructure with WebSocket delivery and optional email transport. +**Auth:** All endpoints require authentication (JWT via `sb_token` +cookie or `Authorization: Bearer` header). + ### CRUD ``` @@ -13,32 +16,102 @@ POST /notifications/mark-all-read → mark all as read DELETE /notifications/:id ``` +**`GET /notifications`** — paginated list for the authenticated user. + +Query parameters: + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `limit` | int | 20 | 1–100, 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: ```json { "id": "uuid", "user_id": "uuid", - "type": "role.fallback|memory.extracted|system.announcement|...", + "type": "role.fallback", "title": "Role Fallback Triggered", "body": "Primary model unavailable, using fallback", - "metadata": {}, - "read": false, - "created_at": "..." + "resource_type": "channel", + "resource_id": "uuid", + "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 Users control per-type delivery: ``` -GET /notifications/preferences → { "preferences": [...] } -PUT /notifications/preferences/:type ← { "in_app": true, "email": false } -DELETE /notifications/preferences/:type → reset to default +GET /notifications/preferences → { "data": [...] } +PUT /notifications/preferences/:type ← { "in_app": true, "email": false } +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 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. + --- diff --git a/docs/ICD/websocket.md b/docs/ICD/websocket.md index 0051ebf..7f2d8b6 100644 --- a/docs/ICD/websocket.md +++ b/docs/ICD/websocket.md @@ -63,6 +63,11 @@ Clients subscribe to rooms for scoped event delivery: | `memory.extracted` | → client | New memory extracted | | `memory.status` | → client | Memory approved/rejected | | `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 | | `project.updated` | → client | Project metadata changed | | `extension.updated` | → client | Extension config changed | @@ -168,8 +173,12 @@ only, `↔ both` = bidirectional. ### Notification Types -`role.fallback`, `memory.extracted`, `system.announcement`, plus -extensible via notification service. +`role.fallback`, `kb.ready`, `kb.error`, `grant.changed`, +`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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d268374..a83d788 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -69,12 +69,20 @@ Audit arc, frontend SDK, and infrastructure improvements. ### v0.28.2 — ICD Audit: Notifications + Profile + Knowledge + Notes + Providers Known ICD report failures to investigate (likely envelope shape mismatches): - [ ] `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 /git-credentials` — `missing key "data"` - [ ] Notes, providers — passed clean but need full trace (route → handler → store → tests) - [ ] WebSocket event contract audit: document `message.created`, `workflow.advanced`, `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) 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 management, per-workspace remote config. Table exists (`git_credentials`), vault 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 `switchboard-sdk.js` — composition layer over existing globals. Surface diff --git a/server/handlers/completion.go b/server/handlers/completion.go index f3f1db0..5b554c5 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -21,6 +21,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/knowledge" "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/routing" "git.gobha.me/xcaliber/chat-switchboard/storage" @@ -584,6 +585,12 @@ func (h *CompletionHandler) Complete(c *gin.Context) { 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}) return } diff --git a/server/handlers/notification_test.go b/server/handlers/notification_test.go new file mode 100644 index 0000000..615b0d9 --- /dev/null +++ b/server/handlers/notification_test.go @@ -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 ¬ifHarness{ + 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 +} diff --git a/server/handlers/notifications.go b/server/handlers/notifications.go index 3597012..9d6d77a 100644 --- a/server/handlers/notifications.go +++ b/server/handlers/notifications.go @@ -184,7 +184,7 @@ func (h *NotificationHandler) ListPreferences(c *gin.Context) { } if h.stores.NotifPrefs == nil { - c.JSON(http.StatusOK, []models.NotificationPreference{}) + c.JSON(http.StatusOK, gin.H{"data": []models.NotificationPreference{}}) return } @@ -196,7 +196,7 @@ func (h *NotificationHandler) ListPreferences(c *gin.Context) { if prefs == nil { prefs = []models.NotificationPreference{} } - c.JSON(http.StatusOK, prefs) + c.JSON(http.StatusOK, gin.H{"data": prefs}) } // PUT /api/v1/notifications/preferences/:type diff --git a/server/handlers/workflow_assignments.go b/server/handlers/workflow_assignments.go index fef803d..1b4ff35 100644 --- a/server/handlers/workflow_assignments.go +++ b/server/handlers/workflow_assignments.go @@ -9,6 +9,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" + "git.gobha.me/xcaliber/chat-switchboard/notifications" ) // ── 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}) } diff --git a/server/memory/extractor.go b/server/memory/extractor.go index fb61501..f373d88 100644 --- a/server/memory/extractor.go +++ b/server/memory/extractor.go @@ -10,6 +10,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/knowledge" "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/roles" "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 { 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 } diff --git a/server/models/models_notification.go b/server/models/models_notification.go index 8687b5b..7207505 100644 --- a/server/models/models_notification.go +++ b/server/models/models_notification.go @@ -23,11 +23,17 @@ type Notification struct { // Notification type constants. Convention: domain.action. // Free-form strings — new types don't require migration. const ( - NotifTypeRoleFallback = "role.fallback" - NotifTypeKBReady = "kb.ready" - NotifTypeKBError = "kb.error" - NotifTypeGrantChanged = "grant.changed" - NotifTypeProjectInvite = "project.invite" + NotifTypeRoleFallback = "role.fallback" + NotifTypeKBReady = "kb.ready" + NotifTypeKBError = "kb.error" + NotifTypeGrantChanged = "grant.changed" + 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. diff --git a/server/notifications/sources.go b/server/notifications/sources.go index ea73202..3b84399 100644 --- a/server/notifications/sources.go +++ b/server/notifications/sources.go @@ -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) } } + +// ── 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) + } +} diff --git a/server/notifications/sources_test.go b/server/notifications/sources_test.go new file mode 100644 index 0000000..f783fd5 --- /dev/null +++ b/server/notifications/sources_test.go @@ -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) + } + } +}