Feat v0.6.15 user display audit
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 3s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 3s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
Add batch user resolve endpoint, sw.users SDK module, and migrate all surfaces to show display_name instead of UUIDs. Canonical fallback chain: display_name → username → "Unknown". - GET /api/v1/users/resolve?ids=... (max 100, map response) - sw.users.resolve(), resolveMany(), displayName() with 60s cache - Chat participants resolved from users table, not snapshot - Admin users/teams/groups/team-admin show display_name - Chat-core participants.display_name column deprecated - 5 new handler tests, OpenAPI spec updated Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,3 +81,38 @@ func (h *PresenceHandler) SearchUsers(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": results})
|
||||
}
|
||||
|
||||
// ResolveUsers returns identity records for a batch of user IDs.
|
||||
// GET /api/v1/users/resolve?ids=uuid1,uuid2,...
|
||||
func (h *PresenceHandler) ResolveUsers(c *gin.Context) {
|
||||
raw := strings.TrimSpace(c.Query("ids"))
|
||||
if raw == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]string, 0)
|
||||
for _, id := range strings.Split(raw, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.stores.Users.ResolveByIDs(c.Request.Context(), ids)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "resolve failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build map keyed by user ID for O(1) client-side lookups
|
||||
m := make(map[string]interface{}, len(results))
|
||||
for _, u := range results {
|
||||
m[u.ID] = u
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": m})
|
||||
}
|
||||
|
||||
179
server/handlers/presence_test.go
Normal file
179
server/handlers/presence_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"armature/config"
|
||||
"armature/database"
|
||||
"armature/middleware"
|
||||
"armature/store"
|
||||
postgres "armature/store/postgres"
|
||||
sqlite "armature/store/sqlite"
|
||||
)
|
||||
|
||||
// ── Presence/Resolve Test Harness ──────────────
|
||||
|
||||
type presenceHarness struct {
|
||||
*testHarness
|
||||
stores store.Stores
|
||||
userToken string
|
||||
userID string
|
||||
}
|
||||
|
||||
func setupPresenceHarness(t *testing.T) *presenceHarness {
|
||||
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)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
|
||||
presence := NewPresenceHandler(stores)
|
||||
protected.GET("/users/resolve", presence.ResolveUsers)
|
||||
protected.GET("/users/search", presence.SearchUsers)
|
||||
|
||||
// Seed a caller user
|
||||
userID := database.SeedTestUser(t, "caller", "caller@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "caller@test.com", "user")
|
||||
|
||||
return &presenceHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
stores: stores,
|
||||
userToken: userToken,
|
||||
userID: userID,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────
|
||||
|
||||
func TestResolveUsers_SingleID(t *testing.T) {
|
||||
h := setupPresenceHarness(t)
|
||||
|
||||
// Seed a user with display_name
|
||||
targetID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, display_name = $1 WHERE id = $2"), "Alice Smith", targetID)
|
||||
|
||||
w := h.request("GET", "/api/v1/users/resolve?ids="+targetID, h.userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data map[string]store.UserSearchResult `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
u, ok := resp.Data[targetID]
|
||||
if !ok {
|
||||
t.Fatalf("expected user %s in response, got %v", targetID, resp.Data)
|
||||
}
|
||||
if u.Username != "alice" {
|
||||
t.Errorf("expected username 'alice', got %q", u.Username)
|
||||
}
|
||||
if u.DisplayName != "Alice Smith" {
|
||||
t.Errorf("expected display_name 'Alice Smith', got %q", u.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsers_MultipleIDs(t *testing.T) {
|
||||
h := setupPresenceHarness(t)
|
||||
|
||||
id1 := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
id2 := database.SeedTestUser(t, "carol", "carol@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), id1)
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, display_name = $1 WHERE id = $2"), "Carol D", id2)
|
||||
|
||||
w := h.request("GET", "/api/v1/users/resolve?ids="+id1+","+id2, h.userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data map[string]store.UserSearchResult `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data) != 2 {
|
||||
t.Fatalf("expected 2 users, got %d", len(resp.Data))
|
||||
}
|
||||
if resp.Data[id1].Username != "bob" {
|
||||
t.Errorf("expected bob, got %q", resp.Data[id1].Username)
|
||||
}
|
||||
if resp.Data[id2].DisplayName != "Carol D" {
|
||||
t.Errorf("expected 'Carol D', got %q", resp.Data[id2].DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsers_MissingID(t *testing.T) {
|
||||
h := setupPresenceHarness(t)
|
||||
|
||||
w := h.request("GET", "/api/v1/users/resolve?ids=00000000-0000-0000-0000-000000000099", h.userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data map[string]store.UserSearchResult `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data) != 0 {
|
||||
t.Errorf("expected empty map for missing ID, got %d entries", len(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsers_EmptyParam(t *testing.T) {
|
||||
h := setupPresenceHarness(t)
|
||||
|
||||
w := h.request("GET", "/api/v1/users/resolve?ids=", h.userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data) != 0 {
|
||||
t.Errorf("expected empty map, got %d entries", len(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsers_NoParam(t *testing.T) {
|
||||
h := setupPresenceHarness(t)
|
||||
|
||||
w := h.request("GET", "/api/v1/users/resolve", h.userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data) != 0 {
|
||||
t.Errorf("expected empty map, got %d entries", len(resp.Data))
|
||||
}
|
||||
}
|
||||
@@ -474,8 +474,9 @@ func main() {
|
||||
protected.POST("/presence/heartbeat", presence.Heartbeat)
|
||||
protected.GET("/presence", presence.Query)
|
||||
|
||||
// User search
|
||||
// User search & resolve
|
||||
protected.GET("/users/search", presence.SearchUsers)
|
||||
protected.GET("/users/resolve", presence.ResolveUsers)
|
||||
|
||||
// Workflows
|
||||
wfH := handlers.NewWorkflowHandler(stores)
|
||||
|
||||
@@ -1355,6 +1355,42 @@ paths:
|
||||
items:
|
||||
type: object
|
||||
|
||||
/api/v1/users/resolve:
|
||||
get:
|
||||
summary: Resolve user identities by ID
|
||||
operationId: resolveUsers
|
||||
tags: [Presence]
|
||||
parameters:
|
||||
- name: ids
|
||||
in: query
|
||||
required: true
|
||||
description: Comma-separated user IDs (max 100)
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Map of user ID to identity record
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
display_name:
|
||||
type: string
|
||||
handle:
|
||||
type: string
|
||||
avatar_url:
|
||||
type: string
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Notifications
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@@ -103,6 +103,10 @@ type UserStore interface {
|
||||
// Excludes the calling user. Max 20 results.
|
||||
SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error)
|
||||
|
||||
// ResolveByIDs returns lightweight identity records for the given user IDs.
|
||||
// Missing IDs are silently omitted. Max 100 IDs.
|
||||
ResolveByIDs(ctx context.Context, ids []string) ([]UserSearchResult, error)
|
||||
|
||||
// ── CS2 additions ──
|
||||
|
||||
// CountAll returns the total number of users.
|
||||
@@ -132,6 +136,7 @@ type UserSearchResult struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Handle string `json:"handle"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
@@ -250,6 +250,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// ResolveByIDs returns lightweight identity records for the given user IDs.
|
||||
func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) {
|
||||
if len(ids) == 0 {
|
||||
return []store.UserSearchResult{}, nil
|
||||
}
|
||||
if len(ids) > 100 {
|
||||
ids = ids[:100]
|
||||
}
|
||||
|
||||
// Build $1,$2,... placeholders
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
q := `SELECT id, username, COALESCE(display_name, '') AS display_name,
|
||||
COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url
|
||||
FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)`
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []store.UserSearchResult
|
||||
for rows.Next() {
|
||||
var u store.UserSearchResult
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, u)
|
||||
}
|
||||
if results == nil {
|
||||
results = []store.UserSearchResult{}
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS2 additions ─────────────────────────────────────────────
|
||||
|
||||
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
|
||||
|
||||
@@ -262,6 +262,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// ResolveByIDs returns lightweight identity records for the given user IDs.
|
||||
func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) {
|
||||
if len(ids) == 0 {
|
||||
return []store.UserSearchResult{}, nil
|
||||
}
|
||||
if len(ids) > 100 {
|
||||
ids = ids[:100]
|
||||
}
|
||||
|
||||
// Build ?,?,... placeholders
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
q := `SELECT id, username, COALESCE(display_name, '') AS display_name,
|
||||
COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url
|
||||
FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)`
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []store.UserSearchResult
|
||||
for rows.Next() {
|
||||
var u store.UserSearchResult
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, u)
|
||||
}
|
||||
if results == nil {
|
||||
results = []store.UserSearchResult{}
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS2 additions ─────────────────────────────────────────────
|
||||
|
||||
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
|
||||
|
||||
Reference in New Issue
Block a user