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,144 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||
|
||||
const grid = document.getElementById('board-grid');
|
||||
const errorBox = document.getElementById('error');
|
||||
let meId = '';
|
||||
let tasks = [];
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
function filters() {
|
||||
return {
|
||||
q: document.getElementById('f-q').value.trim(),
|
||||
customerId: document.getElementById('f-customer').value,
|
||||
minBounty: document.getElementById('f-min').value || '0',
|
||||
sort: document.getElementById('f-sort').value,
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const f = filters();
|
||||
const qs = new URLSearchParams(f).toString();
|
||||
const res = await api('GET', '/api/v1/board?' + qs);
|
||||
tasks = res.tasks;
|
||||
const sel = document.getElementById('f-customer');
|
||||
if (sel.options.length === 1) {
|
||||
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
|
||||
}
|
||||
render();
|
||||
} catch (e) { errorBox.textContent = e.message; }
|
||||
}
|
||||
|
||||
function ageBadge(t) {
|
||||
if (!t.publishedAt) return '';
|
||||
const days = (Date.now() - new Date(t.publishedAt).getTime()) / 86400000;
|
||||
if (days > 14) return '<span class="badge" style="color:var(--err)" title="Published more than 14 days ago">14d+</span>';
|
||||
if (days > 7) return '<span class="badge" style="color:var(--warn)" title="Published more than 7 days ago">7d+</span>';
|
||||
return '';
|
||||
}
|
||||
|
||||
function render() {
|
||||
grid.replaceChildren(...tasks.map(renderCard));
|
||||
if (!tasks.length) {
|
||||
grid.innerHTML = '<p class="muted">No published tasks right now. Consultants add you to their pool to share work here.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(t) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const myClaim = (t.claimRequests || []).some((c) => c.developerId === meId);
|
||||
const others = (t.claimRequests || []).filter((c) => c.developerId !== meId).length;
|
||||
card.innerHTML = `
|
||||
<div class="spread">
|
||||
<span class="badge accent" style="font-size:1rem">◈ ${t.bounty}</span>
|
||||
<span>${ageBadge(t)}</span>
|
||||
</div>
|
||||
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
||||
<p class="muted">${esc((t.description || '').slice(0, 180))}</p>
|
||||
<p class="muted">${(t.acceptanceCriteria || []).length} acceptance criteria</p>
|
||||
<div class="spread">
|
||||
<span class="muted">
|
||||
${myClaim ? '<span class="badge" style="color:var(--ok)">requested by you</span>' : ''}
|
||||
${others ? `<span class="badge">${others} other request(s)</span>` : ''}
|
||||
</span>
|
||||
${myClaim
|
||||
? '<button class="btn small" data-act="withdraw">Withdraw</button>'
|
||||
: '<button class="btn primary small" data-act="claim">Request assignment</button>'}
|
||||
</div>`;
|
||||
const claimBtn = card.querySelector('[data-act=claim]');
|
||||
if (claimBtn) claimBtn.addEventListener('click', () => openClaim(t));
|
||||
const wBtn = card.querySelector('[data-act=withdraw]');
|
||||
if (wBtn) {
|
||||
wBtn.addEventListener('click', async () => {
|
||||
try { await api('POST', `/api/v1/tasks/${t.id}/claim/withdraw`, {}); load(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
const dlg = document.getElementById('claim-dialog');
|
||||
let claimTask = null;
|
||||
function openClaim(t) {
|
||||
claimTask = t;
|
||||
document.getElementById('claim-task-title').textContent = `${t.title} — bounty ${t.bounty}`;
|
||||
dlg.showModal();
|
||||
}
|
||||
document.getElementById('claim-cancel').addEventListener('click', () => dlg.close());
|
||||
document.getElementById('claim-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api('POST', `/api/v1/tasks/${claimTask.id}/claim`, {
|
||||
note: document.getElementById('claim-note').value.trim(),
|
||||
});
|
||||
dlg.close();
|
||||
document.getElementById('claim-note').value = '';
|
||||
toast('Assignment requested — the consultant will review it.', 'ok');
|
||||
load();
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// saved filters (§11.8)
|
||||
document.getElementById('f-save').addEventListener('click', async () => {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
const extra = me.user.extra || {};
|
||||
extra.savedBoardFilters = JSON.stringify(filters());
|
||||
await api('PATCH', '/api/v1/profile', { extra });
|
||||
toast('Filters saved as your default.', 'ok');
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
|
||||
async function restoreFilters() {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
meId = me.user.id;
|
||||
const saved = me.user.extra && me.user.extra.savedBoardFilters;
|
||||
if (saved) {
|
||||
const f = JSON.parse(saved);
|
||||
document.getElementById('f-q').value = f.q || '';
|
||||
document.getElementById('f-min').value = f.minBounty > 0 ? f.minBounty : '';
|
||||
document.getElementById('f-sort').value = f.sort || '';
|
||||
}
|
||||
} catch (e) { /* anonymous — page guard handles it */ }
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
['f-q', 'f-min'].forEach((id) => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(load, 300);
|
||||
});
|
||||
});
|
||||
['f-customer', 'f-sort'].forEach((id) => {
|
||||
document.getElementById(id).addEventListener('change', load);
|
||||
});
|
||||
|
||||
subscribe('board', () => { clearTimeout(timer); timer = setTimeout(load, 400); });
|
||||
onPollFallback(load);
|
||||
restoreFilters().then(load);
|
||||
Reference in New Issue
Block a user