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

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:
2026-04-01 14:19:03 +00:00
parent c9b9e68c18
commit a8204859b5
21 changed files with 542 additions and 35 deletions

View File

@@ -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: {

View File

@@ -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
View 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 });
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>`)}