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
25 changes: 20 additions & 5 deletions cloudflare-worker/src/routes/admin_ticket_reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
* tasks. Mounted at /api/admin/ticket-reviews.
*
* GET /features feature requests not yet decided
* { items, count, total }
* POST /features/:ticketId/approve { reason }
* POST /features/:ticketId/decline { reason } the user is sent it
* GET /tasks suggested tasks still pending
* { items, count, total, audiences }
* POST /tasks/:id/approve { audience, reason }
* POST /tasks/:id/decline { reason } the user is sent it
*
Expand All @@ -26,7 +28,11 @@
* through D540's `grantCredits`, keyed `task:<id>` so it is paid once.
* Declining sends the reason.
*
* The page that drives these waits on its design (#1107 is `needs-design`).
* D606 — the page is `/hq/review` (`pages/hq/ReviewQueuePage.jsx`), built to
* the owner's Eadwyn Tickets canvas. A list reads the oldest 200 waiting;
* `count` is how many came back and `total` how many are waiting, so a page
* past the 200th says how many it cannot show rather than a short number.
* A task carries its GitHub issue's number beside its link, as a feature does.
*/
import { Hono, type Context } from 'hono';
import type { Env } from '../types';
Expand Down Expand Up @@ -113,7 +119,12 @@ r.get('/features', async (c) => {
ticket_id: x.id, title: x.title, body: x.description, priority: x.priority, created_at: x.created_at,
asked_by: asker(x), github_issue_number: x.github_issue_number ?? null, github_issue_url: x.github_issue_url ?? null,
}));
return c.json({ items, count: items.length });
const waiting = await c.env.DB.prepare(
`SELECT COUNT(*) AS n FROM tickets t
WHERE t.type = 'feature'
AND NOT EXISTS (SELECT 1 FROM feature_request_reviews f WHERE f.ticket_id = t.id)`,
).first<{ n: number }>();
return c.json({ items, count: items.length, total: Math.max(Number(waiting?.n ?? 0), items.length) });
} catch (e) { return mapError(c, e); }
});

Expand Down Expand Up @@ -163,7 +174,7 @@ r.get('/tasks', async (c) => {
await requireSuperAdmin(c);
const rows = await c.env.DB.prepare(
`SELECT ct.id, ct.ticket_id, ct.title, ct.body, ct.created_at, t.priority, t.user_id, t.submitted_by,
t.github_issue_url, u.name AS user_name, u.email AS user_email
t.github_issue_number, t.github_issue_url, u.name AS user_name, u.email AS user_email
FROM community_tasks ct
JOIN tickets t ON t.id = ct.ticket_id
LEFT JOIN users u ON u.id = t.user_id
Expand All @@ -172,9 +183,13 @@ r.get('/tasks', async (c) => {
).all<any>();
const items = (rows.results || []).map((x: any) => ({
id: x.id, ticket_id: x.ticket_id, title: x.title, body: x.body, priority: x.priority, created_at: x.created_at,
asked_by: asker(x), github_issue_url: x.github_issue_url ?? null,
asked_by: asker(x), github_issue_number: x.github_issue_number ?? null, github_issue_url: x.github_issue_url ?? null,
}));
return c.json({ items, count: items.length, audiences: AUDIENCES });
const waiting = await c.env.DB.prepare(
`SELECT COUNT(*) AS n FROM community_tasks ct JOIN tickets t ON t.id = ct.ticket_id
WHERE ct.status = 'pending' AND t.type = 'task'`,
).first<{ n: number }>();
return c.json({ items, count: items.length, total: Math.max(Number(waiting?.n ?? 0), items.length), audiences: AUDIENCES });
} catch (e) { return mapError(c, e); }
});

Expand Down
75 changes: 75 additions & 0 deletions cloudflare-worker/src/routes/community_tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@
* null when this page is the last. Older suggestions are
* one request away, never silently cut off.
*
* GET /rewards { rewards } (D606)
*
* rewards what an approved suggestion and a fixed bug earn today,
* from D540's `credit_reward_rules`, for the Eadwyn panels'
* notes: `task_approved`, `bug_high`, `bug_medium` and
* `bug_low`, each `{ credits, active }`, or null when the
* table has no such rule. A failed read is a refusal,
* never zeros.
*
* D606 — each approved suggestion also carries `reward`: the credits its
* grant actually paid (the ledger row D544's approval wrote, keyed
* `task:<id>`), what the suggester got rather than what the rule says now.
* `credits` is null when no grant was paid (refused, or the rule was off);
* `revoked` says a grant was taken back; a ledger that cannot be read says
* `credits_unreadable` and the list is still served. A pending or declined
* suggestion carries null.
*
* The store is D544's `community_tasks` (migration 383). A read that fails is
* a 500 refusal, never an empty list: "No tasks yet" and "could not read the
* tasks" are different sentences, and the panel prints each.
Expand All @@ -35,6 +52,41 @@ const r = new Hono<{ Bindings: Env }>();

const SUGGESTIONS_PAGE = 100;

/** The rules the Eadwyn panels state, in the order the notes name them. */
export const PANEL_REWARD_KEYS = ['task_approved', 'bug_high', 'bug_medium', 'bug_low'] as const;

export type SuggestionReward = { credits: number | null; revoked: boolean } | { credits: null; credits_unreadable: true } | null;

/**
* The grant each approved suggestion paid, from the ledger. Same reading as
* D542's `bountySummaries`: the grant's own delta, revoked when an
* `admin_adjust` row names it.
*/
async function suggestionRewards(env: Env, userId: number, rows: Array<{ id: number; status: string }>): Promise<Map<number, SuggestionReward>> {
const out = new Map<number, SuggestionReward>();
const approved = rows.filter((x) => x.status === 'approved');
for (const x of rows) out.set(Number(x.id), null);
if (!approved.length) return out;
try {
const { results } = await env.DB.prepare(
`SELECT g.source_ref, g.delta,
EXISTS (SELECT 1 FROM perk_credit_ledger r
WHERE r.user_id = g.user_id AND r.kind = 'admin_adjust' AND r.source_ref = 'revoke:' || g.id) AS revoked
FROM perk_credit_ledger g
WHERE g.user_id = ? AND g.kind = 'grant' AND g.source_ref IN (SELECT value FROM json_each(?))`,
).bind(userId, JSON.stringify(approved.map((x) => `task:${x.id}`))).all<{ source_ref: string; delta: number; revoked: number }>();
const byRef = new Map((results || []).map((g) => [g.source_ref, g]));
for (const x of approved) {
const g = byRef.get(`task:${x.id}`);
out.set(Number(x.id), { credits: g ? Number(g.delta) : null, revoked: !!(g && Number(g.revoked)) });
}
} catch (e) {
console.warn('[community-tasks] ledger unreadable for the suggestion rewards', (e as Error).message);
for (const x of approved) out.set(Number(x.id), { credits: null, credits_unreadable: true });
}
return out;
}

/** The audience a role reads, or null for "only the tasks for everyone". */
export function audienceFor(role: unknown): string | null {
const v = String(role ?? '').toLowerCase();
Expand Down Expand Up @@ -75,6 +127,7 @@ r.get('/', async (c) => {
const mineRows = mine.results || [];
const page = mineRows.slice(0, SUGGESTIONS_PAGE);
const next = mineRows.length > SUGGESTIONS_PAGE ? page[page.length - 1].id : null;
const rewards = await suggestionRewards(c.env, Number(user.id), page);
return c.json({
audience: seesAll ? 'admin' : (audience ?? 'all'),
tasks: (tasks.results || []).map((x: any) => ({
Expand All @@ -85,6 +138,7 @@ r.get('/', async (c) => {
// The reason belongs to a decline only; a pending or approved row has none.
decline_reason: x.status === 'declined' ? (x.decline_reason ?? null) : null,
created_at: x.created_at, decided_at: x.decided_at ?? null,
reward: rewards.get(Number(x.id)) ?? null,
})),
my_suggestions_next: next,
});
Expand All @@ -97,4 +151,25 @@ r.get('/', async (c) => {
}
});

r.get('/rewards', async (c) => {
try { await requireAuth(c); } catch (e) { return mapError(c, e); }
try {
const { results } = await c.env.DB.prepare(
`SELECT key, credits, active FROM credit_reward_rules WHERE key IN (SELECT value FROM json_each(?))`,
).bind(JSON.stringify(PANEL_REWARD_KEYS)).all<{ key: string; credits: number; active: number }>();
const byKey = new Map((results || []).map((x) => [x.key, x]));
const rewards = Object.fromEntries(PANEL_REWARD_KEYS.map((k) => {
const x = byKey.get(k);
return [k, x ? { credits: Number(x.credits), active: Number(x.active) === 1 } : null];
}));
return c.json({ rewards });
} catch (e) {
return refuse(c, 500, {
code: 'rewards_unreadable',
message: 'What a task or a bug report earns could not be read. This is not a sign it earns nothing; try again.',
raw: e,
});
}
});

export default r;
Loading
Loading