feat: bounty board, task lifecycle, AI work performer flow, notifications (phase 8)
- whitelist HTML sanitizer (stdlib tokenizer) with XSS vector tests - developer board: pool-scoped visibility, customer/search/minBounty/sort filters, stale-task age badges, competing-claims visibility setting, saved filters in profile extras - claims: request/withdraw (developer), approve/decline (consultant) with notifications to winners and losers; unassign/abandon back to board - work tracking: start, sanitized comments with @mention notifications, time logging, submit for review - review queue + review with per-AC checklist stored on the timeline; approve writes the immutable bountyAwards row (human assignees only) - assign-to-AI: §5.2 job submission, HMAC-verified callback endpoint, idempotent by jobId, artifacts downloaded into GridFS, failure path keeps the task assigned with timeline + notification - notifications API + bell with unread badge, dropdown, page, WS toasts - pages: bounty board, my-tasks kanban, task detail (role-driven actions, review dialog, AI dialog), review queue, developer pool Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
// Global notifications: bell with unread badge, dropdown, and WS toasts for
|
||||
// every lifecycle transition (§11.1).
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
|
||||
if (document.body.dataset.loggedIn === '1') {
|
||||
const bell = document.getElementById('nav-bell');
|
||||
const badge = document.getElementById('nav-bell-count');
|
||||
const panel = document.getElementById('notif-panel');
|
||||
const list = document.getElementById('notif-list');
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/notifications?limit=15');
|
||||
badge.textContent = res.unread > 0 ? String(res.unread) : '';
|
||||
badge.hidden = res.unread === 0;
|
||||
list.replaceChildren(...res.notifications.map((n) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<a href="${esc(n.link || '#')}">
|
||||
<strong>${esc(n.title)}</strong>${n.readAt ? '' : ' <span class="badge accent">new</span>'}<br>
|
||||
<span class="muted">${esc(n.body || '')}</span></a>`;
|
||||
return li;
|
||||
}));
|
||||
if (!res.notifications.length) {
|
||||
list.innerHTML = '<li class="muted">No notifications.</li>';
|
||||
}
|
||||
} catch (e) { /* signed out or transient */ }
|
||||
}
|
||||
|
||||
bell.addEventListener('click', async () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
if (!panel.hidden) {
|
||||
await refresh();
|
||||
try { await api('POST', '/api/v1/notifications/read', { ids: [] }); } catch (e) { /* ignore */ }
|
||||
badge.hidden = true;
|
||||
badge.textContent = '';
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!panel.hidden && !panel.contains(e.target) && e.target !== bell) panel.hidden = true;
|
||||
});
|
||||
|
||||
subscribe('notifications', (event, n) => {
|
||||
if (event === 'notification' && n) {
|
||||
toast(n.title, 'ok');
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
onPollFallback(refresh);
|
||||
refresh();
|
||||
}
|
||||
Reference in New Issue
Block a user