Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/db/certificationQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,18 @@ const stmtGetPendingCertificationReviewRequest = db.prepare<[string], Certificat
`)

const stmtListCertificationReviewRequests = db.prepare<
[string | null, string | null, number],
[string | null, string | null, string | null, string | null, string | null, string | null, number],
CertificationReviewRequestRow
>(
`
${CERTIFICATION_REVIEW_SELECT}
WHERE (? IS NULL OR r.status = ?)
AND (
? IS NULL
OR LOWER(r.wallet) LIKE ?
OR LOWER(COALESCE(reg.name, '')) LIKE ?
OR LOWER(COALESCE(reg.description, '')) LIKE ?
)
ORDER BY r.requested_at DESC, r.id DESC
LIMIT ?
`,
Expand Down Expand Up @@ -301,8 +307,13 @@ export function getPendingCertificationReviewRequest(wallet: string): Certificat
return stmtGetPendingCertificationReviewRequest.get(wallet)
}

export function listCertificationReviewRequests(status: string | null, limit: number): CertificationReviewRequestRow[] {
return stmtListCertificationReviewRequests.all(status, status, limit)
export function listCertificationReviewRequests(
status: string | null,
search: string | null,
limit: number,
): CertificationReviewRequestRow[] {
const searchLike = search ? `%${search}%` : null
return stmtListCertificationReviewRequests.all(status, status, search, searchLike, searchLike, searchLike, limit)
}

export function updateCertificationReviewRequestDecision(
Expand Down
1 change: 1 addition & 0 deletions src/routes/certification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ certification.get('/admin/all', adminAuth, (c) => {
certification.get('/admin/reviews', adminAuth, (c) => {
const outcome = listCertificationReviewRequestViews({
status: c.req.query('status'),
search: c.req.query('search'),
limit: c.req.query('limit'),
})
if (!outcome.ok) {
Expand Down
12 changes: 11 additions & 1 deletion src/services/certificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export interface CertificationReviewRequestView {
export interface CertificationReviewQueueView {
filters: {
status: CertificationReviewStatus | null
search: string | null
limit: number
}
returned: number
Expand Down Expand Up @@ -329,6 +330,12 @@ function normalizeReviewNote(rawNote: string | null | undefined): string | null
return note.slice(0, 500)
}

function normalizeSearchTerm(rawSearch: string | null | undefined): string | null {
const normalized = rawSearch?.trim().toLowerCase()
if (!normalized) return null
return normalized.slice(0, 120)
}

function buildCertificationLinks(wallet: string): CertificationStatusView['links'] {
return {
certification_badge: buildPublicUrl(`/v1/certification/badge/${wallet}`),
Expand Down Expand Up @@ -822,18 +829,21 @@ export function submitCertificationReviewRequest(params: {

export function listCertificationReviewRequestViews(params: {
status?: string | null | undefined
search?: string | null | undefined
limit?: string | null | undefined
}): CertificationResult<CertificationReviewQueueView> {
const parsedLimit = Number.parseInt(params.limit ?? '50', 10)
const limit = Number.isNaN(parsedLimit) ? 50 : Math.min(Math.max(parsedLimit, 1), 200)
const status = normalizeReviewStatus(params.status)
const requests = listCertificationReviewRequests(status, limit).map(buildCertificationReviewView)
const search = normalizeSearchTerm(params.search)
const requests = listCertificationReviewRequests(status, search, limit).map(buildCertificationReviewView)

return {
ok: true,
data: {
filters: {
status,
search,
limit,
},
returned: requests.length,
Expand Down
100 changes: 96 additions & 4 deletions src/templates/reviewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export function reviewerPageHtml(): string {
<label for="adminKey">Admin Key</label>
<input id="adminKey" type="password" placeholder="Enter x-admin-key for reviewer actions" autocomplete="off">
</div>
<div class="field">
<label for="searchFilter">Search</label>
<input id="searchFilter" type="search" placeholder="Wallet, name, or description" autocomplete="off">
</div>
<div class="field" style="max-width:170px">
<label for="statusFilter">Status</label>
<select id="statusFilter">
Expand All @@ -144,6 +148,7 @@ export function reviewerPageHtml(): string {
<button class="btn btn-primary" id="loadBtn" onclick="loadQueue()">Load Review Queue</button>
<button class="btn btn-secondary" onclick="clearDashboard()">Clear</button>
</div>
<div class="note">This dashboard keeps the admin key and queue filters in <code>sessionStorage</code> for the current tab only.</div>
<div class="status" id="statusMsg"></div>
<div class="error" id="errorMsg"></div>
</div>
Expand All @@ -158,6 +163,8 @@ export function reviewerPageHtml(): string {

<script>
const REVIEWS_API='/v1/certification/admin/reviews';
const REVIEWER_STORAGE_KEY='djd-reviewer-dashboard';
const DEFAULT_LIMIT='24';

function esc(value){
return String(value)
Expand Down Expand Up @@ -216,15 +223,70 @@ function getAdminKey(){
return document.getElementById('adminKey').value.trim();
}

function getSearchFilter(){
return document.getElementById('searchFilter').value.trim();
}

function getDashboardState(){
return {
adminKey: document.getElementById('adminKey').value,
search: document.getElementById('searchFilter').value,
status: document.getElementById('statusFilter').value,
limit: document.getElementById('limitFilter').value || DEFAULT_LIMIT
};
}

function persistDashboardState(){
try{
window.sessionStorage.setItem(REVIEWER_STORAGE_KEY, JSON.stringify(getDashboardState()));
}catch{
// Session persistence is best-effort only.
}
}

function restoreDashboardState(){
try{
const rawState=window.sessionStorage.getItem(REVIEWER_STORAGE_KEY);
if(!rawState) return false;
const state=JSON.parse(rawState);
if(!state || typeof state !== 'object') return false;

if(typeof state.adminKey === 'string') document.getElementById('adminKey').value=state.adminKey;
if(typeof state.search === 'string') document.getElementById('searchFilter').value=state.search;
if(typeof state.status === 'string') document.getElementById('statusFilter').value=state.status;
if(typeof state.limit === 'string' && state.limit) document.getElementById('limitFilter').value=state.limit;
return Boolean(typeof state.adminKey === 'string' && state.adminKey.trim());
}catch{
return false;
}
}

function clearStoredDashboardState(){
try{
window.sessionStorage.removeItem(REVIEWER_STORAGE_KEY);
}catch{
// Session persistence is best-effort only.
}
}

function getQueueParams(){
const params=new URLSearchParams();
const status=document.getElementById('statusFilter').value;
const search=getSearchFilter();
const limit=document.getElementById('limitFilter').value;
if(status) params.set('status', status);
if(search) params.set('search', search);
if(limit) params.set('limit', limit);
return params;
}

function buildQueueMeta(body){
const parts=['Showing ' + body.returned + ' review request(s)'];
if(body.filters && body.filters.status) parts.push(labelForStatus(body.filters.status));
if(body.filters && body.filters.search) parts.push('matching "' + body.filters.search + '"');
return parts.join(' · ');
}

function noteBlock(label, value){
return value ? '<div class="note"><strong>' + label + ':</strong> ' + esc(value) + '</div>' : '';
}
Expand Down Expand Up @@ -272,6 +334,8 @@ async function loadQueue(){
return;
}

persistDashboardState();

const button=document.getElementById('loadBtn');
button.disabled=true;
button.textContent='Loading...';
Expand All @@ -285,7 +349,7 @@ async function loadQueue(){
throw new Error(body && body.error && body.error.message ? body.error.message : 'Unable to load review queue');
}

document.getElementById('queueMeta').textContent='Showing ' + body.returned + ' review request(s)';
document.getElementById('queueMeta').textContent=buildQueueMeta(body);
document.getElementById('queue').innerHTML=body.returned
? body.requests.map(renderRequest).join('')
: '<div class="card empty">No review requests matched the current filter.</div>';
Expand All @@ -304,6 +368,7 @@ async function decision(id, status){
showError('Enter the admin key before submitting reviewer actions.');
return;
}
persistDashboardState();
const note=window.prompt('Optional reviewer note for ' + labelForStatus(status).toLowerCase() + ':', '');
if(note === null && status !== 'approved'){
return;
Expand Down Expand Up @@ -339,6 +404,7 @@ async function issueRequest(id){
showError('Enter the admin key before issuing certifications.');
return;
}
persistDashboardState();
if(!window.confirm('Issue a certification from this approved review request?')){
return;
}
Expand All @@ -363,11 +429,37 @@ function clearDashboard(){
document.getElementById('queue').innerHTML='';
document.getElementById('queueMeta').textContent='Queue idle';
document.getElementById('adminKey').value='';
document.getElementById('searchFilter').value='';
document.getElementById('statusFilter').value='';
document.getElementById('limitFilter').value=DEFAULT_LIMIT;
clearStoredDashboardState();
}

function attachPersistenceListeners(){
['adminKey','searchFilter'].forEach(function(id){
document.getElementById(id).addEventListener('input', persistDashboardState);
});
['statusFilter','limitFilter'].forEach(function(id){
document.getElementById(id).addEventListener('change', persistDashboardState);
});
}

function attachLoadShortcuts(){
['adminKey','searchFilter'].forEach(function(id){
document.getElementById(id).addEventListener('keydown', function(event){
if(event.key === 'Enter') loadQueue();
});
});
}

function initDashboard(){
const restoredAdminKey=restoreDashboardState();
attachPersistenceListeners();
attachLoadShortcuts();
if(restoredAdminKey) loadQueue();
}

document.getElementById('adminKey').addEventListener('keydown', function(event){
if(event.key === 'Enter') loadQueue();
});
initDashboard();
</script>
</body>
</html>`
Expand Down
33 changes: 31 additions & 2 deletions tests/routes/certification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ vi.mock('../../src/db.js', () => ({
LIMIT 1`,
)
.get(wallet),
listCertificationReviewRequests: (status: string | null, limit: number) =>
listCertificationReviewRequests: (status: string | null, search: string | null, limit: number) =>
testDb
.prepare(
`SELECT
Expand All @@ -247,10 +247,24 @@ vi.mock('../../src/db.js', () => ({
LEFT JOIN agent_registrations reg ON reg.wallet = r.wallet
LEFT JOIN scores s ON s.wallet = r.wallet
WHERE (? IS NULL OR r.status = ?)
AND (
? IS NULL
OR LOWER(r.wallet) LIKE ?
OR LOWER(COALESCE(reg.name, '')) LIKE ?
OR LOWER(COALESCE(reg.description, '')) LIKE ?
)
ORDER BY r.requested_at DESC, r.id DESC
LIMIT ?`,
)
.all(status, status, limit),
.all(
status,
status,
search,
search ? `%${search}%` : null,
search ? `%${search}%` : null,
search ? `%${search}%` : null,
limit,
),
updateCertificationReviewRequestDecision: (
id: number,
status: string,
Expand Down Expand Up @@ -652,6 +666,21 @@ describe('Certification routes', () => {
expect(decisionBody.status).toBe('approved')
expect(decisionBody.reviewed_by).toBe('ops')
expect(decisionBody.review_note).toContain('issuance review')

const filteredQueueRes = await app.request('/v1/certification/admin/reviews?status=approved&search=queue', {
headers: { 'x-admin-key': ADMIN_KEY },
})
expect(filteredQueueRes.status).toBe(200)
const filteredQueueBody = (await filteredQueueRes.json()) as {
filters: { status: string | null; search: string | null }
returned: number
requests: Array<{ profile: { name: string | null } }>
}

expect(filteredQueueBody.filters.status).toBe('approved')
expect(filteredQueueBody.filters.search).toBe('queue')
expect(filteredQueueBody.returned).toBe(1)
expect(filteredQueueBody.requests[0]?.profile.name).toBe('Queue Candidate')
})

it('issues certification from an approved review request', async () => {
Expand Down
3 changes: 3 additions & 0 deletions tests/routes/reviewerPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,8 @@ describe('GET /reviewer', () => {
expect(body).toContain('/v1/certification/admin/reviews')
expect(body).toContain('Issue Certification')
expect(body).toContain('Enter the admin key')
expect(body).toContain('Wallet, name, or description')
expect(body).toContain('sessionStorage')
expect(body).toContain('djd-reviewer-dashboard')
})
})