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

@@ -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": "",
})

View File

@@ -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' : '')} />

View File

@@ -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');

View File

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