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