70a813edfa
- 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>
145 lines
5.2 KiB
JavaScript
145 lines
5.2 KiB
JavaScript
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);
|