Changeset 0.28.2 (#183)
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 | 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:
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
558
server/handlers/notification_test.go
Normal file
558
server/handlers/notification_test.go
Normal 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 ¬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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
288
server/notifications/sources_test.go
Normal file
288
server/notifications/sources_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user