Feat v0.6.15 user display audit (#50)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #50.
This commit is contained in:
35
CHANGELOG.md
35
CHANGELOG.md
@@ -2,6 +2,41 @@
|
||||
|
||||
All notable changes to Armature are documented here.
|
||||
|
||||
## v0.6.15 — User Display Audit
|
||||
|
||||
Every user-facing identity surface now shows human-readable names instead of
|
||||
UUIDs, with the canonical fallback chain: `display_name → username → "Unknown"`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`GET /api/v1/users/resolve?ids=...`** — batch endpoint returns identity
|
||||
records (username, display_name, handle, avatar_url) for up to 100 user IDs.
|
||||
Response keyed by ID for O(1) client lookups.
|
||||
- **`sw.users` SDK module** — `resolve(id)`, `resolveMany(ids)`,
|
||||
`displayName(user)` with 60-second local cache. Surfaces use this instead
|
||||
of ad-hoc lookups or stale snapshots.
|
||||
- **5 handler tests** for the resolve endpoint (single, multiple, missing,
|
||||
empty, no-param).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Admin users list** — shows `display_name || username` as primary
|
||||
identifier, with username shown as secondary when display_name is set.
|
||||
- **Admin teams/groups member lists** — replaced `username || user_id`
|
||||
with `display_name || username || 'Unknown'`.
|
||||
- **Team-admin members** — dropdown and list now show display_name.
|
||||
- **Chat participants** — resolved from users table via `sw.users.resolveMany()`
|
||||
instead of relying on creation-time snapshot. Message sender names, typing
|
||||
indicators, and participant sidebar all use resolved names.
|
||||
- **Dashboard greeting** — added `|| 'Unknown'` terminal fallback.
|
||||
- **Team activity log** — capitalized fallback to `'Unknown'`.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- **`participants.display_name` column** in chat-core — column retained for
|
||||
backward compatibility but UI no longer relies on snapshot values. Comments
|
||||
added to `packages/chat-core/script.star` noting deprecation.
|
||||
|
||||
## v0.6.14 — Visual Polish
|
||||
|
||||
Systematic cleanup of stale values, self-hosted fonts, and rendering fixes.
|
||||
|
||||
@@ -91,17 +91,13 @@ Shipped. Stale fallback colors purged (~65 instances), fonts self-hosted
|
||||
regressions (half-step tokens), theme settings default, surface overflow,
|
||||
and user menu zoom drift. See CHANGELOG.md.
|
||||
|
||||
### v0.6.15 — User Display Audit
|
||||
### v0.6.15 — User Display Audit ✅
|
||||
|
||||
Every place a user identity appears in the UI must show a human-readable
|
||||
name, never a UUID. Fallback chain: `display_name → username → "Unknown"`.
|
||||
|
||||
| Step | Description |
|
||||
|------|-------------|
|
||||
| Participant display-name resolution | `chat-core` participants endpoint resolves `display_name` from the users table at query time instead of relying on a creation-time snapshot. If the stored `display_name` is empty, look up `users.display_name` or `users.username` by `participant_id`. |
|
||||
| Audit all user-facing identity surfaces | Walk every surface that shows user identity: chat participant list, chat message sender names, chat typing indicators, workflow assignees, team member lists, admin user list, notification actor names. Ensure the fallback chain is `display_name → username → "Unknown"` everywhere. |
|
||||
| SDK user-resolve utility | Add `sw.users.resolve(id)` or similar to the SDK — returns `{id, username, display_name, avatar}` with local caching. Surfaces use this instead of ad-hoc lookups. |
|
||||
| Stale snapshot cleanup | Remove or deprecate the static `display_name` column in the participants table. All display names come from the users table via the resolve utility. |
|
||||
Shipped. `GET /api/v1/users/resolve?ids=...` batch endpoint, `sw.users`
|
||||
SDK module with `resolve()`, `resolveMany()`, `displayName()` + 60s cache.
|
||||
Chat participants resolved from users table instead of snapshot. All admin
|
||||
surfaces show `display_name || username || 'Unknown'`. Chat-core snapshot
|
||||
column deprecated. 5 new handler tests. See CHANGELOG.md.
|
||||
|
||||
### v0.6.16 — Usability Survey Gate
|
||||
|
||||
@@ -133,7 +129,7 @@ v0.6.13 Responsive & Spacing ✅ SHIPPED
|
||||
↓
|
||||
v0.6.14 Visual Polish ✅ SHIPPED
|
||||
↓
|
||||
v0.6.15 User Display Audit ← No UUIDs in the UI, ever
|
||||
v0.6.15 User Display Audit ✅ SHIPPED
|
||||
↓
|
||||
v0.6.16 Usability Survey Gate ← Machine-audit the finished product
|
||||
```
|
||||
|
||||
@@ -91,6 +91,9 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
|
||||
cid = conv["id"]
|
||||
|
||||
# Add creator as admin participant
|
||||
# NOTE: display_name is a snapshot captured at creation time.
|
||||
# DEPRECATED v0.6.15 — UI resolves display names from users table via sw.users.resolve().
|
||||
# Column retained for backward compatibility; UI no longer relies on this value for display.
|
||||
if creator_id:
|
||||
db.insert("participants", {
|
||||
"conversation_id": cid,
|
||||
@@ -110,7 +113,7 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"participant_type": _str(p.get("type", "user")),
|
||||
"display_name": _str(p.get("display_name", "")),
|
||||
"display_name": _str(p.get("display_name", "")), # DEPRECATED v0.6.15 — snapshot only
|
||||
"role": _str(p.get("role", "member")),
|
||||
"joined_at": "",
|
||||
})
|
||||
@@ -213,7 +216,7 @@ def add_participant(conversation_id, participant_id, participant_type="user", di
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"participant_type": _str(participant_type),
|
||||
"display_name": _str(display_name),
|
||||
"display_name": _str(display_name), # DEPRECATED v0.6.15 — snapshot only
|
||||
"role": _str(role),
|
||||
"joined_at": "",
|
||||
})
|
||||
|
||||
@@ -243,9 +243,9 @@
|
||||
onMouseEnter=${() => setHover(true)}
|
||||
onMouseLeave=${() => setHover(false)}>
|
||||
${!isOwn && html`
|
||||
<${Avatar} name=${msg._display_name || msg.participant_id} size="sm" />`}
|
||||
<${Avatar} name=${msg._display_name || 'Unknown'} size="sm" />`}
|
||||
<div class="ext-chat-msg__body">
|
||||
${!isOwn && html`<span class="ext-chat-msg__name">${msg._display_name || msg.participant_id}</span>`}
|
||||
${!isOwn && html`<span class="ext-chat-msg__name">${msg._display_name || 'Unknown'}</span>`}
|
||||
${editing ? html`
|
||||
<div class="ext-chat-msg__edit">
|
||||
<textarea class="ext-chat-msg__edit-input"
|
||||
@@ -285,20 +285,32 @@
|
||||
var [hasMore, setHasMore] = useState(false);
|
||||
var [nextCursor, setNextCursor] = useState('');
|
||||
var [typingUsers, setTypingUsers] = useState({});
|
||||
var [resolvedNames, setResolvedNames] = useState({});
|
||||
var bottomRef = useRef(null);
|
||||
var listRef = useRef(null);
|
||||
var userId = currentUserId();
|
||||
|
||||
// Build participant lookup
|
||||
// Resolve participant display names from users table (not snapshot)
|
||||
useEffect(() => {
|
||||
var ids = (participants || []).map(p => p.participant_id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
sw.users.resolveMany(ids).then(map => {
|
||||
var names = {};
|
||||
map.forEach((user, id) => { names[id] = sw.users.displayName(user); });
|
||||
setResolvedNames(names);
|
||||
});
|
||||
}, [participants]);
|
||||
|
||||
// Build participant lookup from resolved names
|
||||
var partMap = useMemo(() => {
|
||||
var m = {};
|
||||
(participants || []).forEach(p => { m[p.participant_id] = p.display_name || p.participant_id; });
|
||||
(participants || []).forEach(p => { m[p.participant_id] = resolvedNames[p.participant_id] || p.display_name || 'Unknown'; });
|
||||
return m;
|
||||
}, [participants]);
|
||||
}, [participants, resolvedNames]);
|
||||
|
||||
// Enrich messages with display names
|
||||
function enrichMessages(msgs) {
|
||||
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || m.participant_id }));
|
||||
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || 'Unknown' }));
|
||||
}
|
||||
|
||||
// Load initial messages
|
||||
@@ -361,7 +373,7 @@
|
||||
|
||||
var unsubs = [
|
||||
sw.realtime.subscribe(channel, 'message', (payload) => {
|
||||
var msg = { ...payload, _display_name: partMap[payload.participant_id] || payload.participant_id };
|
||||
var msg = { ...payload, _display_name: partMap[payload.participant_id] || 'Unknown' };
|
||||
setMessages(prev => [...prev, msg]);
|
||||
setTimeout(() => scrollToBottom(), 50);
|
||||
// Auto mark read if from someone else
|
||||
@@ -386,7 +398,8 @@
|
||||
unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
|
||||
var pid = payload.participant_id;
|
||||
if (pid === userId) return;
|
||||
setTypingUsers(prev => ({ ...prev, [pid]: payload.display_name || pid }));
|
||||
var typingName = resolvedNames[pid] || payload.display_name || 'Someone';
|
||||
setTypingUsers(prev => ({ ...prev, [pid]: typingName }));
|
||||
clearTimeout(typingTimers[pid]);
|
||||
typingTimers[pid] = setTimeout(() => {
|
||||
setTypingUsers(prev => {
|
||||
@@ -553,6 +566,18 @@
|
||||
function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
|
||||
var [addOpen, setAddOpen] = useState(false);
|
||||
var [presence, setPresence] = useState({});
|
||||
var [resolvedNames, setResolvedNames] = useState({});
|
||||
|
||||
// Resolve display names from users table
|
||||
useEffect(() => {
|
||||
var ids = (participants || []).map(p => p.participant_id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
sw.users.resolveMany(ids).then(map => {
|
||||
var names = {};
|
||||
map.forEach((user, id) => { names[id] = sw.users.displayName(user); });
|
||||
setResolvedNames(names);
|
||||
});
|
||||
}, [participants]);
|
||||
|
||||
// Query presence
|
||||
useEffect(() => {
|
||||
@@ -588,9 +613,9 @@
|
||||
<div class="ext-chat-participants__list">
|
||||
${(participants || []).map(p => html`
|
||||
<div key=${p.participant_id} class="ext-chat-participants__item">
|
||||
<${Avatar} name=${p.display_name || p.participant_id} size="sm" />
|
||||
<${Avatar} name=${resolvedNames[p.participant_id] || p.display_name || 'Unknown'} size="sm" />
|
||||
<span class="ext-chat-participants__name">
|
||||
${p.display_name || p.participant_id}
|
||||
${resolvedNames[p.participant_id] || p.display_name || 'Unknown'}
|
||||
${p.role === 'admin' && html`<span class="ext-chat-participants__badge">admin</span>`}
|
||||
</span>
|
||||
<span class=${'chat-participants__status' + (presence[p.participant_id] ? ' ext-chat-participants__status--online' : '')} />
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
const user = sw.user;
|
||||
const greeting = document.createElement('div');
|
||||
greeting.className = 'ext-dashboard-greeting';
|
||||
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username) : '');
|
||||
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username || 'Unknown') : '');
|
||||
main.appendChild(greeting);
|
||||
|
||||
const subtitle = document.createElement('div');
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
<div key=${e.id} class="ext-team-activity-log-entry">
|
||||
<div class="ext-team-activity-log-entry__header">
|
||||
<span class="ext-team-activity-log-entry__category badge">${e.category}</span>
|
||||
<span class="ext-team-activity-log-entry__user">${esc(e.username || 'unknown')}</span>
|
||||
<span class="ext-team-activity-log-entry__user">${esc(e.username || 'Unknown')}</span>
|
||||
<span class="ext-team-activity-log-entry__time">${timeAgo(e.created_at)}</span>
|
||||
<button class="ext-team-activity-log-entry__delete" onClick=${() => onDelete(e.id)}
|
||||
title="Delete">×</button>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -287,7 +287,8 @@ export function createDomains(restClient) {
|
||||
|
||||
// ── Users ──────────────────────────────
|
||||
users: {
|
||||
search: (q) => rc.get('/api/v1/users/search' + _qs({ q })),
|
||||
search: (q) => rc.get('/api/v1/users/search' + _qs({ q })),
|
||||
resolve: (ids) => rc.get('/api/v1/users/resolve' + _qs({ ids: ids.join(',') })),
|
||||
},
|
||||
|
||||
presence: {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createActions } from './actions.js';
|
||||
import { createRealtime } from './realtime.js';
|
||||
import { createRenderers } from './renderers.js';
|
||||
import { createMarkdown } from './markdown.js';
|
||||
import { createUsers } from './users.js';
|
||||
import { confirm } from '../primitives/confirm.js';
|
||||
import { prompt } from '../primitives/prompt.js';
|
||||
|
||||
@@ -117,6 +118,9 @@ export async function boot() {
|
||||
// Markdown — unified renderer (marked + DOMPurify + sw.renderers pipeline)
|
||||
sw.markdown = markdown;
|
||||
|
||||
// Users — identity resolution with local caching
|
||||
sw.users = createUsers(restClient);
|
||||
|
||||
// Shell helpers — imperative confirm/prompt backed by primitives
|
||||
sw.confirm = confirm;
|
||||
sw.prompt = prompt;
|
||||
|
||||
104
src/js/sw/sdk/users.js
Normal file
104
src/js/sw/sdk/users.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// ==========================================
|
||||
// Armature — SDK Users Module
|
||||
// ==========================================
|
||||
// Resolves user identity by ID with local caching.
|
||||
//
|
||||
// Usage:
|
||||
// const user = await sw.users.resolve(id);
|
||||
// const name = sw.users.displayName(user);
|
||||
// const map = await sw.users.resolveMany([id1, id2]);
|
||||
//
|
||||
// Cache: 60-second TTL per entry. Batch-fetches missing
|
||||
// IDs in a single GET /api/v1/users/resolve?ids=... call.
|
||||
// ==========================================
|
||||
|
||||
const CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
/**
|
||||
* @param {object} restClient — SDK rest client (rc.get, rc.post, etc.)
|
||||
* @returns {object} sw.users namespace
|
||||
*/
|
||||
export function createUsers(restClient) {
|
||||
/** @type {Map<string, {data: object, fetchedAt: number}>} */
|
||||
const _cache = new Map();
|
||||
|
||||
function _isFresh(entry) {
|
||||
return entry && (Date.now() - entry.fetchedAt) < CACHE_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve multiple user IDs to identity records.
|
||||
* Deduplicates, checks cache, fetches remainder in one API call.
|
||||
*
|
||||
* @param {string[]} ids
|
||||
* @returns {Promise<Map<string, {id, username, display_name, avatar_url}>>}
|
||||
*/
|
||||
async function resolveMany(ids) {
|
||||
const unique = [...new Set(ids)].filter(Boolean);
|
||||
const result = new Map();
|
||||
const toFetch = [];
|
||||
|
||||
for (const id of unique) {
|
||||
const cached = _cache.get(id);
|
||||
if (_isFresh(cached)) {
|
||||
result.set(id, cached.data);
|
||||
} else {
|
||||
toFetch.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.length > 0) {
|
||||
try {
|
||||
const resp = await restClient.get(
|
||||
'/api/v1/users/resolve?ids=' + encodeURIComponent(toFetch.join(','))
|
||||
);
|
||||
// REST client does NOT auto-unwrap {data: Object} (only arrays).
|
||||
const data = (resp && resp.data) || resp || {};
|
||||
for (const id of toFetch) {
|
||||
const user = data[id] || { id, username: '', display_name: '', avatar_url: '' };
|
||||
_cache.set(id, { data: user, fetchedAt: Date.now() });
|
||||
result.set(id, user);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[sw.users] resolve failed:', err.message);
|
||||
// Return empty placeholders for unfetched IDs
|
||||
for (const id of toFetch) {
|
||||
const placeholder = { id, username: '', display_name: '', avatar_url: '' };
|
||||
result.set(id, placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single user ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<{id, username, display_name, avatar_url}>}
|
||||
*/
|
||||
async function resolve(id) {
|
||||
const m = await resolveMany([id]);
|
||||
return m.get(id) || { id, username: '', display_name: '', avatar_url: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a user object for display using the canonical fallback chain.
|
||||
* Pure/synchronous — no network call.
|
||||
*
|
||||
* @param {object|null} user — object with display_name and/or username
|
||||
* @returns {string}
|
||||
*/
|
||||
function displayName(user) {
|
||||
if (!user) return 'Unknown';
|
||||
return user.display_name || user.username || 'Unknown';
|
||||
}
|
||||
|
||||
/** Flush the cache (useful after profile updates). */
|
||||
function clearCache() {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
return Object.freeze({ resolve, resolveMany, displayName, clearCache });
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export default function GroupsSection() {
|
||||
<div class="admin-list">
|
||||
${members.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id || m.user_id}>
|
||||
<span style="flex:1;">${m.username || m.user_id}</span>
|
||||
<span style="flex:1;">${m.display_name || m.username || 'Unknown'}</span>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => removeMember(m.user_id || m.id)}>Remove</button>
|
||||
</div>
|
||||
`)}
|
||||
@@ -155,7 +155,7 @@ export default function GroupsSection() {
|
||||
<div class="form-group"><label>User</label>
|
||||
<select name="userId">
|
||||
<option value="">Select\u2026</option>
|
||||
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.username}</option>`)}
|
||||
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.display_name || u.username}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function TeamsSection() {
|
||||
<div class="admin-list">
|
||||
${members.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id}>
|
||||
<span style="flex:1;">${m.username || m.user_id}</span>
|
||||
<span style="flex:1;">${m.display_name || m.username || 'Unknown'}</span>
|
||||
<select value=${m.role} onChange=${e => updateMemberRole(m.id, e.target.value)}
|
||||
style="font-size:11px;padding:2px 4px;">
|
||||
<option value="member">member</option>
|
||||
@@ -124,7 +124,7 @@ export default function TeamsSection() {
|
||||
<div class="form-group"><label>User</label>
|
||||
<select name="userId">
|
||||
<option value="">Select user\u2026</option>
|
||||
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.username}</option>`)}
|
||||
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.display_name || u.username}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Role</label>
|
||||
|
||||
@@ -128,7 +128,8 @@ export default function UsersSection() {
|
||||
${filtered.map(u => html`
|
||||
<div class="admin-surface-row" key=${u.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${u.username}</strong>
|
||||
<strong>${u.display_name || u.username}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.display_name ? u.username : ''}</span>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span>
|
||||
${' '}${statusBadge(u)}
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function MembersSection({ teamId }) {
|
||||
<div class="form-group"><label>User</label>
|
||||
<select name="userId">
|
||||
<option value="">Select\u2026</option>
|
||||
${users.map(u => html`<option key=${u.id} value=${u.id}>${u.username || u.email}</option>`)}
|
||||
${users.map(u => html`<option key=${u.id} value=${u.id}>${u.display_name || u.username || u.email}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Role</label>
|
||||
@@ -108,7 +108,7 @@ export default function MembersSection({ teamId }) {
|
||||
${members.length === 0 && html`<div class="empty-hint">No members</div>`}
|
||||
${members.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id || m.user_id}>
|
||||
<strong style="flex:1;">${m.username || m.user_id}</strong>
|
||||
<strong style="flex:1;">${m.display_name || m.username || 'Unknown'}</strong>
|
||||
<select value=${m.role || 'member'} style="width:100px;"
|
||||
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
|
||||
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
|
||||
Reference in New Issue
Block a user