Changeset 0.28.2 (#183)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user