From 224e2e0157a5a509bec6cf20239b53aadfe3d26d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 22:07:42 +0000 Subject: [PATCH] D606: the Eadwyn Tickets canvas, built (#1107, #1108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's Eadwyn Tickets canvas, its four screens built: - T1, T1b: /hq/review, the Super Admin's review queue. Two tabs counting what waits (withheld, never 0, while a list is loading or unreadable), a title search, Priority and Date filters, "1-25 of [N]", the most pressing first, and Approve and Decline on the item they decide, each with a typed reason (D544's rule). A task has the audience picker and "Approve · +N credits", N from the credit rules. - T2, T3: the Eadwyn chat's panel. Conversation, Tasks (community tasks with Start, my suggestions with what each grant paid, Suggest a task) and My tickets (the balance, each ticket's bounty or review, what a fixed bug earns by tier). Worker first: the review lists return the number waiting (`total`) and a task's issue number; each approved suggestion carries the credits its grant paid; GET /api/community-tasks/rewards gives members the amounts the panels state. The chat's ticket form loses its preset type (D544), except from "Suggest a task". H7 links a feature request awaiting review to the queue, for the Super Admin. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jkNYLq29UXAvcqYxGCSB3 --- .../src/routes/admin_ticket_reviews.ts | 25 +- .../src/routes/community_tasks.ts | 75 +++ .../test/eadwyn_tickets_d606.test.ts | 220 +++++++++ .../test/ticket_type_required_d544.test.ts | 11 +- design/canvases/README.md | 12 +- .../integrated/Eadwyn Tickets Screen.dc.html | 140 ++++++ .../integrated/Eadwyn Tickets.dc.html | 39 ++ design/incoming/README.md | 7 +- documentation/architecture/PAGE_INVENTORY.md | 2 +- documentation/architecture/PROFILE_ROUTING.md | 14 +- documentation/architecture/ROUTE_MAP.md | 2 + documentation/architecture/decisions/D544.md | 3 +- documentation/architecture/decisions/D545.md | 3 +- documentation/architecture/decisions/D604.md | 4 +- documentation/architecture/decisions/D606.md | 171 +++++++ frontend/src/App.jsx | 3 + .../src/components/advisor/EadwynPanels.jsx | 265 ++++++++++ .../components/advisor/PersonalAdvisor.jsx | 123 +++-- frontend/src/lib/api.js | 1 + frontend/src/lib/api/communityTasks.js | 4 + frontend/src/lib/api/ticketReviews.js | 18 + frontend/src/lib/eadwynTickets.js | 269 +++++++++++ frontend/src/pages/TicketsPage.jsx | 12 +- frontend/src/pages/hq/HqSupportPage.jsx | 6 + frontend/src/pages/hq/ReviewQueuePage.jsx | 457 ++++++++++++++++++ frontend/src/sidebarConfig.js | 3 +- frontend/test/eadwyn_tickets_d606.test.mjs | 382 +++++++++++++++ frontend/test/profile_routing_fresh.test.mjs | 4 +- 28 files changed, 2218 insertions(+), 57 deletions(-) create mode 100644 cloudflare-worker/test/eadwyn_tickets_d606.test.ts create mode 100644 design/canvases/integrated/Eadwyn Tickets Screen.dc.html create mode 100644 design/canvases/integrated/Eadwyn Tickets.dc.html create mode 100644 documentation/architecture/decisions/D606.md create mode 100644 frontend/src/components/advisor/EadwynPanels.jsx create mode 100644 frontend/src/lib/api/ticketReviews.js create mode 100644 frontend/src/lib/eadwynTickets.js create mode 100644 frontend/src/pages/hq/ReviewQueuePage.jsx create mode 100644 frontend/test/eadwyn_tickets_d606.test.mjs diff --git a/cloudflare-worker/src/routes/admin_ticket_reviews.ts b/cloudflare-worker/src/routes/admin_ticket_reviews.ts index bea0b65de6..2c3d826e39 100644 --- a/cloudflare-worker/src/routes/admin_ticket_reviews.ts +++ b/cloudflare-worker/src/routes/admin_ticket_reviews.ts @@ -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 * @@ -26,7 +28,11 @@ * through D540's `grantCredits`, keyed `task:` 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'; @@ -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); } }); @@ -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 @@ -172,9 +183,13 @@ r.get('/tasks', async (c) => { ).all(); 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); } }); diff --git a/cloudflare-worker/src/routes/community_tasks.ts b/cloudflare-worker/src/routes/community_tasks.ts index 97f129ef11..726968aeb8 100644 --- a/cloudflare-worker/src/routes/community_tasks.ts +++ b/cloudflare-worker/src/routes/community_tasks.ts @@ -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:`), 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. @@ -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> { + const out = new Map(); + 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(); @@ -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) => ({ @@ -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, }); @@ -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; diff --git a/cloudflare-worker/test/eadwyn_tickets_d606.test.ts b/cloudflare-worker/test/eadwyn_tickets_d606.test.ts new file mode 100644 index 0000000000..03ee75506a --- /dev/null +++ b/cloudflare-worker/test/eadwyn_tickets_d606.test.ts @@ -0,0 +1,220 @@ +/** + * D606 (#1107, #1108) — what the Eadwyn Tickets screens read from the Worker. + * + * - The review queue's lists say how many are waiting (`total`), not only + * how many came back: a list reads the oldest 200, so a queue past the + * 200th would otherwise show a short number. A task carries its GitHub + * issue's number, as a feature does. + * - Each approved suggestion carries `reward`, the credits its grant paid + * (the ledger row D544's approval writes, keyed `task:`), and says + * when the grant was taken back or the ledger could not be read. + * - `GET /api/community-tasks/rewards` gives every signed-in member the + * amounts the panels state, from D540's rules; a failed read is a + * refusal, never zeros. + * + * The real routers run over node:sqlite with `users`, `tickets`, + * `activity_logs` and the ledger from `schema_baseline.sql`, plus migrations + * 381 (the rules) and 383 (the review store, its triggers included). + * + * Run: + * node --experimental-strip-types --no-warnings --import ./cloudflare-worker/test/_ts-loader.mjs \ + * --test cloudflare-worker/test/eadwyn_tickets_d606.test.ts + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { SignJWT } from 'jose'; + +import reviews from '../src/routes/admin_ticket_reviews.ts'; +import communityTasks, { PANEL_REWARD_KEYS } from '../src/routes/community_tasks.ts'; + +const JWT_SECRET = 'unit-test-jwt-secret-0123456789-abcdef'; +const SUPER = 1, FOUNDER = 10, OTHER = 11; +const ROLE: Record = { [SUPER]: 'admin', [FOUNDER]: 'founder', [OTHER]: 'founder' }; +const read = (p: string) => readFileSync(resolve(process.cwd(), p), 'utf8'); +const BASELINE = read('cloudflare-worker/sql/schema_baseline.sql'); +function ddl(name: string): string { + const at = `\n${BASELINE}`.indexOf(`\nCREATE TABLE ${name} (`); + assert.ok(at >= 0, `${name} is no longer defined in schema_baseline.sql`); + return BASELINE.slice(at, BASELINE.indexOf(');', at) + 2); +} + +function freshDb() { + const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: false, enableDoubleQuotedStringLiterals: true }); + for (const t of ['users', 'tickets', 'activity_logs', 'perk_credit_ledger']) db.exec(ddl(t)); + db.exec(`CREATE UNIQUE INDEX idx_perk_ledger_idem ON perk_credit_ledger(user_id, kind, source_ref); + CREATE TABLE super_admins (user_id INTEGER PRIMARY KEY, granted_by_user_id INTEGER, granted_at TEXT);`); + db.exec(read('cloudflare-worker/sql/migrations/381_credit_reward_rules.sql')); + db.exec(read('cloudflare-worker/sql/migrations/383_community_tasks.sql')); + const u = db.prepare(`INSERT INTO users (id, role, name, email, is_active) VALUES (?, ?, ?, ?, 1)`); + for (const [id, role] of Object.entries(ROLE)) u.run(Number(id), role, `U${id}`, `u${id}@example.test`); + db.prepare('INSERT INTO super_admins (user_id) VALUES (?)').run(SUPER); + return db; +} + +const ticket = (db: any, type: string, o: { user?: number; issue?: number | null; title?: string } = {}) => + Number(db.prepare(`INSERT INTO tickets (title, description, priority, type, user_id, submitted_by, github_issue_number, github_issue_url) + VALUES (?, 'Details', 'high', ?, ?, 'U', ?, ?)`) + .run(o.title ?? `A ${type}`, type, o.user ?? FOUNDER, o.issue ?? null, + o.issue ? `https://github.com/acme/tickets/issues/${o.issue}` : null).lastInsertRowid); +const taskRowFor = (db: any, ticketId: number) => Number((db.prepare('SELECT id FROM community_tasks WHERE ticket_id = ?').get(ticketId) as any).id); +const decide = (db: any, taskId: number, status: 'approved' | 'declined', reason: string | null = null) => + db.prepare(`UPDATE community_tasks SET status = ?, decline_reason = ?, decided_by = ?, decided_at = '2026-10-05T10:00:00.000Z' WHERE id = ?`) + .run(status, reason, SUPER, taskId); +const grant = (db: any, userId: number, ref: string, delta: number) => + Number(db.prepare(`INSERT INTO perk_credit_ledger (user_id, delta, kind, source_ref, note, rule_key) VALUES (?, ?, 'grant', ?, 'x', 'task_approved')`) + .run(userId, delta, ref).lastInsertRowid); +const revoke = (db: any, userId: number, grantId: number, delta: number) => + db.prepare(`INSERT INTO perk_credit_ledger (user_id, delta, kind, source_ref, note) VALUES (?, ?, 'admin_adjust', ?, 'taken back')`) + .run(userId, -delta, `revoke:${grantId}`); + +function makeD1(db: InstanceType, failOn?: RegExp) { + return { + prepare(sql: string) { + let b: any[] = []; + const boom = () => { if (failOn && failOn.test(sql)) throw new Error('D1_ERROR: disk I/O error'); }; + const api: any = { + bind: (...x: any[]) => { b = x.map((v) => (v === undefined ? null : v)); return api; }, + async first() { boom(); return db.prepare(sql).get(...b) ?? null; }, + async all() { boom(); return { results: db.prepare(sql).all(...b) }; }, + async run() { + boom(); + const r = db.prepare(sql).run(...b); + return { meta: { last_row_id: Number(r.lastInsertRowid), changes: Number(r.changes) } }; + }, + }; + return api; + }, + }; +} + +async function call(router: any, db: any, path: string, who: number | null, failOn?: RegExp) { + const headers: Record = {}; + if (who !== null) { + headers.Authorization = `Bearer ${await new SignJWT({ user_id: who, role: ROLE[who] }).setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt().setExpirationTime('1h').sign(new TextEncoder().encode(JWT_SECRET))}`; + } + const res = await router.request(path, { headers }, { JWT_SECRET, ENVIRONMENT: 'development', DB: makeD1(db, failOn) } as any); + return { status: res.status, body: (await res.json().catch(() => null)) as any }; +} +const quiet = async (fn: () => Promise) => { + const w = console.warn, e = console.error; console.warn = () => {}; console.error = () => {}; + try { return await fn(); } finally { console.warn = w; console.error = e; } +}; + +// ---- the review queue's lists ------------------------------------------------------ + +test('the feature list says how many are waiting, past the 200 it returns, and leaves decided ones out', async () => { + const db = freshDb(); + const ins = db.prepare(`INSERT INTO tickets (title, priority, type, user_id) VALUES (?, 'low', 'feature', ?)`); + for (let i = 0; i < 203; i++) ins.run(`Feature ${i}`, FOUNDER); + const decided = Number((db.prepare(`SELECT id FROM tickets ORDER BY id LIMIT 1`).get() as any).id); + db.prepare(`INSERT INTO feature_request_reviews (ticket_id, status, decided_by, decided_at) VALUES (?, 'approved', ?, '2026-10-05')`).run(decided, SUPER); + ticket(db, 'bug'); + const r = await call(reviews, db, '/features', SUPER); + assert.equal(r.status, 200, JSON.stringify(r.body)); + assert.equal(r.body.items.length, 200); + assert.equal(r.body.count, 200); + assert.equal(r.body.total, 202, 'the decided feature or the bug was counted, or the page capped the total'); +}); + +test('the task list carries each issue number and a total of pending suggestions only', async () => { + const db = freshDb(); + const a = ticket(db, 'task', { issue: 1301, title: 'Share a cap-table template' }); + const b = ticket(db, 'task', { title: 'Post your first update' }); + const c = ticket(db, 'task', { title: 'Declined idea' }); + decide(db, taskRowFor(db, c), 'declined', 'Not useful to others'); + ticket(db, 'feature'); + const r = await call(reviews, db, '/tasks', SUPER); + assert.equal(r.status, 200, JSON.stringify(r.body)); + assert.equal(r.body.total, 2); + assert.equal(r.body.count, 2); + const by = Object.fromEntries(r.body.items.map((x: any) => [x.ticket_id, x])); + assert.equal(by[a].github_issue_number, 1301); + assert.equal(by[a].github_issue_url, 'https://github.com/acme/tickets/issues/1301'); + assert.equal(by[b].github_issue_number, null); + assert.deepEqual(r.body.audiences, ['all', 'founder', 'investor', 'advisor', 'partner', 'explorer']); +}); + +test('the lists stay the Super Admin\'s', async () => { + const db = freshDb(); + for (const path of ['/features', '/tasks']) { + const r = await quiet(() => call(reviews, db, path, FOUNDER)); + assert.equal(r.status, 403, path); + assert.equal('total' in (r.body || {}), false); + } +}); + +// ---- what each approved suggestion was paid ------------------------------------------ + +test('an approved suggestion carries the credits its grant paid; pending and declined carry none', async () => { + const db = freshDb(); + const paid = taskRowFor(db, ticket(db, 'task', { title: 'Paid' })); + const back = taskRowFor(db, ticket(db, 'task', { title: 'Taken back' })); + const unpaid = taskRowFor(db, ticket(db, 'task', { title: 'Approved, nothing paid' })); + const pending = taskRowFor(db, ticket(db, 'task', { title: 'Pending' })); + const declined = taskRowFor(db, ticket(db, 'task', { title: 'Declined' })); + for (const id of [paid, back, unpaid]) decide(db, id, 'approved'); + decide(db, declined, 'declined', 'Not useful to others'); + grant(db, FOUNDER, `task:${paid}`, 10); + const g = grant(db, FOUNDER, `task:${back}`, 12); + revoke(db, FOUNDER, g, 12); + // Someone else's grant under the same key shape is not this member's. + grant(db, OTHER, `task:${unpaid}`, 10); + const r = await call(communityTasks, db, '/', FOUNDER); + assert.equal(r.status, 200, JSON.stringify(r.body)); + const by = Object.fromEntries(r.body.my_suggestions.map((s: any) => [s.id, s.reward])); + assert.deepEqual(by[paid], { credits: 10, revoked: false }); + assert.deepEqual(by[back], { credits: 12, revoked: true }); + assert.deepEqual(by[unpaid], { credits: null, revoked: false }, 'another member\'s grant was read as this one\'s'); + assert.equal(by[pending], null); + assert.equal(by[declined], null); +}); + +test('a ledger that cannot be read says so on each approved suggestion, and the list is still served', async () => { + const db = freshDb(); + const ok = taskRowFor(db, ticket(db, 'task', { title: 'Approved' })); + const pending = taskRowFor(db, ticket(db, 'task', { title: 'Pending' })); + decide(db, ok, 'approved'); + grant(db, FOUNDER, `task:${ok}`, 10); + const r = await quiet(() => call(communityTasks, db, '/', FOUNDER, /FROM perk_credit_ledger g/)); + assert.equal(r.status, 200, JSON.stringify(r.body)); + const by = Object.fromEntries(r.body.my_suggestions.map((s: any) => [s.id, s.reward])); + assert.deepEqual(by[ok], { credits: null, credits_unreadable: true }); + assert.equal(by[pending], null); +}); + +// ---- what the panels state --------------------------------------------------------- + +test('the rewards read gives the four amounts the panels state, as the rules hold them today', async () => { + const db = freshDb(); + db.prepare(`UPDATE credit_reward_rules SET credits = 175 WHERE key = 'bug_high'`).run(); + db.prepare(`UPDATE credit_reward_rules SET active = 0 WHERE key = 'bug_low'`).run(); + const r = await call(communityTasks, db, '/rewards', FOUNDER); + assert.equal(r.status, 200, JSON.stringify(r.body)); + assert.deepEqual(Object.keys(r.body.rewards), [...PANEL_REWARD_KEYS]); + assert.deepEqual(r.body.rewards, { + task_approved: { credits: 10, active: true }, + bug_high: { credits: 175, active: true }, + bug_medium: { credits: 60, active: true }, + bug_low: { credits: 20, active: false }, + }); + assert.doesNotMatch(JSON.stringify(r.body), /feature_shipped|updated_by/); +}); + +test('a rule the table lacks is null, never zero; a failed read is a refusal; signed out is refused', async () => { + const db = freshDb(); + db.prepare(`DELETE FROM credit_reward_rules WHERE key = 'task_approved'`).run(); + const r = await call(communityTasks, db, '/rewards', FOUNDER); + assert.equal(r.status, 200); + assert.equal(r.body.rewards.task_approved, null); + const bad = await quiet(() => call(communityTasks, db, '/rewards', FOUNDER, /FROM credit_reward_rules/)); + assert.equal(bad.status, 500); + assert.equal(bad.body.error, 'rewards_unreadable'); + assert.equal('rewards' in bad.body, false); + assert.doesNotMatch(JSON.stringify(bad.body), /disk I\/O/); + const out = await call(communityTasks, db, '/rewards', null); + assert.equal(out.status, 401); +}); diff --git a/cloudflare-worker/test/ticket_type_required_d544.test.ts b/cloudflare-worker/test/ticket_type_required_d544.test.ts index f1c91cdb23..e53a931f00 100644 --- a/cloudflare-worker/test/ticket_type_required_d544.test.ts +++ b/cloudflare-worker/test/ticket_type_required_d544.test.ts @@ -82,6 +82,15 @@ test('both callers that create tickets send a type', () => { /const found = formErrors\(form\);[\s\S]{0,200}if \(found\.type\) \{ typeRef\.current\?\.focus\(\); return; \}[\s\S]*const res = await api\.createTicket\(form\)/, 'the Tickets page form sends a ticket without a type', ); + // D606 (#1107, #1108) — the chat's form follows the same rule: no default + // type, except from the Tasks panel's "Suggest a task", which opens it on + // Task; the submit stops before the request while none is picked. const pa = readFileSync('frontend/src/components/advisor/PersonalAdvisor.jsx', 'utf8'); - assert.match(pa, /const \[type, setType\] = useState\('task'\);/); + assert.match(pa, /const \[type, setType\] = useState\(initialType \|\| ''\);/, 'the chat form presets a type again'); + assert.match( + pa, + /if \(!TICKET_TYPES\.some\(\(t\) => t\.id === type\)\) \{[\s\S]{0,120}return;\s*\}[\s\S]{0,400}api\.createTicket\(/, + 'the chat form sends a ticket without a type', + ); + assert.match(pa, /const suggestTask = useCallback\(\(\) => \{ setTicketPreset\('task'\); setTicketOpen\(true\); \}, \[\]\);/); }); diff --git a/design/canvases/README.md b/design/canvases/README.md index 8b8cb03b0f..21b54d432f 100644 --- a/design/canvases/README.md +++ b/design/canvases/README.md @@ -5,7 +5,7 @@ The corpus the first integration pass worked from. Sorted by one question: | Folder | Count | Meaning | | --- | --- | --- | -| `integrated/` | 79 | A route is running on main. Graded `CURRENT`, `UPGRADE` or `RESKIN` — the surface exists, and for `UPGRADE`/`RESKIN` the canvas is a *diff against* something already live. | +| `integrated/` | 81 | A route is running on main. Graded `CURRENT`, `UPGRADE` or `RESKIN` — the surface exists, and for `UPGRADE`/`RESKIN` the canvas is a *diff against* something already live. | | `backlog/` | 28 | Graded `NEW` or `DEFERRED`. No route yet. | | `out-of-scope/` | 26 | Deliberately not being built. The `ROUTE_MAP.md` row says why. | | `shared/` | 6 | The dc-runtime bundle every canvas loads, plus two standalone pitch-deck exports. Generated — both code scanners ignore this tree. Every canvas asks for it as `src="./support.js"`, which resolves next to the canvas and **not** to this folder, so opening one straight from `integrated/` in a browser gets a blank page: copy `shared/support.js` beside it first. Nothing runs these at runtime, so the reference is nominal — it is a convention marking "the shared runtime, not an inlined 69KB copy", and `scripts/read-canvas.mjs` reproduces it when it decodes a new export. | @@ -88,9 +88,17 @@ They came from the intake queue, not `backlog/`, so `backlog/` did not move. `Ops Page` is the owner's newer export of the same day, whose one change is the Account center's section list on a phone. +**2026-10-06 (D606), two canvases arrived with their routes.** The owner's +Eadwyn Tickets canvas (#1107, #1108) is a board, `Eadwyn Tickets`, and the +component every artboard imports, `Eadwyn Tickets Screen`. Its four screens +are built in the same change: the Super Admin's review queue at `/hq/review` +(T1, T1b) and the Eadwyn chat's Tasks and My tickets panels (T2, T3). So both +files went straight to `integrated/`, as `Help Ticket Screen` did: +`integrated/` went **79 → 81**. Nothing left another folder. + **The `107` in the title is the first audit's corpus, not today's total.** It is `54 + 26 + 27` — the sum as it stood when `integrated/` read 54. The three -folders hold `79 + 28 + 26 = 133` as of 2026-10-06, counted by listing them, +folders hold `81 + 28 + 26 = 135` as of 2026-10-06, counted by listing them, and the title keeps 107 because the paragraph below uses it for the audit it names. diff --git a/design/canvases/integrated/Eadwyn Tickets Screen.dc.html b/design/canvases/integrated/Eadwyn Tickets Screen.dc.html new file mode 100644 index 0000000000..cf5ff28285 --- /dev/null +++ b/design/canvases/integrated/Eadwyn Tickets Screen.dc.html @@ -0,0 +1,140 @@ + + + + + + + + + + + + +
+
+ + + +
+
+
{{ area }}{{ route }}
+
[n] credits · Perks →[AB]
+
+ + + +
+

Review queue

Feature requests and suggested tasks filed from the Eadwyn chat. Approve sends a feature to the build queue; approving a task publishes it and rewards the suggester.
+
{{ t.t }} {{ t.n }}
+
Search titles…{{ f }} ▾1–25 of [N]
+ +
+
+
{{ i.title }}{{ i.prio }}{{ i.type }}
+

{{ i.text }}

+
Asked by [User name]{{ i.date }}GitHub #[n] ↗
+
+
+
Publish to{{ i.audience }}▾
+
{{ approveLabel }}Decline…
+
+
+
+
+
Approve · confirm
{{ approveCopy }}Audience: {{ confirmAudience }} · suggester [User name] receives +10 credits
Cancel{{ approveLabel }}
+
Decline · reason required
The user sees this reason on their ticket.Type the reason…
CancelDecline
Decline stays disabled until a reason is typed.
+
+
+
Loading
+
Empty{{ emptyCopy }}
+
UnreadableThe queue could not be read. The count on the tab is withheld, not zero.Try again
+
+
+
+ + + +
+
+
E
Eadwyn
Drafts, explains and summarises. You decide.
+
+
[Eadwyn message]
+
[Your message]
+
[Eadwyn message]
+
+
Message Eadwyn, or type "open a ticket"…Send
+
+ +
+
+
+
+
+
+ + diff --git a/design/canvases/integrated/Eadwyn Tickets.dc.html b/design/canvases/integrated/Eadwyn Tickets.dc.html new file mode 100644 index 0000000000..f3f0810139 --- /dev/null +++ b/design/canvases/integrated/Eadwyn Tickets.dc.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + +
+ +
+
{{ b.id }}{{ b.title }}{{ b.route }}
+
+
+
+
+
{{ b.cap }}
+
+
+
+
+ + diff --git a/design/incoming/README.md b/design/incoming/README.md index 8cc1625396..2c48580154 100644 --- a/design/incoming/README.md +++ b/design/incoming/README.md @@ -14,7 +14,7 @@ exists for each — not by how far each has been built. Counts as of 2026-10-06: | Folder | Meaning | | --- | --- | -| `canvases/integrated/` | 79 — a live route exists for this canvas. **Not "it is finished"** — most are `UPGRADE`, meaning the route runs and the canvas asks for more than it does. `design/canvases/README.md` has a section on exactly this misreading; the gloss here read "the canvas is built" until 2026-09-07, which is what caused it. | +| `canvases/integrated/` | 81 — a live route exists for this canvas. **Not "it is finished"** — most are `UPGRADE`, meaning the route runs and the canvas asks for more than it does. `design/canvases/README.md` has a section on exactly this misreading; the gloss here read "the canvas is built" until 2026-09-07, which is what caused it. | | `canvases/backlog/` | 28 — graded `NEW` or `DEFERRED`: no route yet. | | `canvases/out-of-scope/` | 26 — deliberately not being built. | @@ -36,8 +36,9 @@ and 74 / 30 / 26 until 2026-10-06, when `Pages · Company analysis` moved from 75 / 29 / 26 until `Founder Studio` moved the same way the same day, when the founder's `/studio` was built to it (D564), and 76 / 28 / 26 until `Help Ticket Screen` arrived beside `Help Center`, whose new export imports it -(D599), and 77 / 28 / 26 until `Ops · Boards` and `Ops Page` graduated with -all five of their areas built (D607). +(D599), 77 / 28 / 26 until `Ops · Boards` and `Ops Page` graduated with +all five of their areas built (D607), and 79 / 28 / 26 until the Eadwyn +Tickets canvas's two files arrived with their routes built (D606). **If you move a file, move the number.**) **A canvas whose bodies are built does not belong in this folder.** Move it to diff --git a/documentation/architecture/PAGE_INVENTORY.md b/documentation/architecture/PAGE_INVENTORY.md index 074dd15b7a..394ae74e4e 100644 --- a/documentation/architecture/PAGE_INVENTORY.md +++ b/documentation/architecture/PAGE_INVENTORY.md @@ -150,4 +150,4 @@ rather than merely absent. - branch_admin · Branch · Insights (`/branch/insights`) - branch_admin · Branch · Settings (`/branch/settings`) -**Canvas corpus:** 135. **Workspace assignments:** 177. +**Canvas corpus:** 137. **Workspace assignments:** 179. diff --git a/documentation/architecture/PROFILE_ROUTING.md b/documentation/architecture/PROFILE_ROUTING.md index 9fa3124daa..c11bd2a989 100644 --- a/documentation/architecture/PROFILE_ROUTING.md +++ b/documentation/architecture/PROFILE_ROUTING.md @@ -13,8 +13,8 @@ Nothing here is typed by hand, so nothing here can disagree with them. each canvas and why. This document answers a different question: *who sees it, where in their nav, and how do they get there.* -**Corpus:** 135 canonical canvases at `design/canvases/`, listed -177 times below — a canvas serving two workspaces appears under both. +**Corpus:** 137 canonical canvases at `design/canvases/`, listed +179 times below — a canvas serving two workspaces appears under both. ## Column meanings @@ -181,12 +181,13 @@ it, where in their nav, and how do they get there.* | Support · Subsidiary | `/help` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | | Team · Authority | `/admin/team` · `/ic` · `/ic/:uid` | — (no nav entry) | Full page | Deep link / in-page action | NEW | Medium | -## Super Admin — 7 canvases +## Super Admin — 8 canvases | Canvas | Route | Nav section | Surface | Entry point | Status | Confidence | | --- | --- | --- | --- | --- | --- | --- | | Admin · Super | `/hq` · `/admin/licences` · `/admin/accounts` · `/admin/contracts` · `/admin/funds` · `/admin` · `/admin/*` | HQ | Full page | Sidebar → HQ → Home | UPGRADE | High | | Contracts · Super | `/admin/contracts` · `/admin/contracts/registry` | HQ | Full page | Sidebar → HQ → Contracts | UPGRADE | High | +| Eadwyn Tickets · Review queue (T1, T1b, D606) | `/hq/review` | HQ | Full page | Sidebar → HQ → Support | CURRENT | High | | Founder Journey Map | — | — (not routed) | Full page (proposed) | Not reachable yet | NEW | Low | | Fund Administration | `/funds/performance` · `/funds/lp-workspace` · `/spinout-lab/investor-workspace` · `/lp-reports` · `/portfolio/waterfall` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | | Funds · Fabric | `/admin/fabric` · `/funds` | — (not routed) | Full page (proposed) | Not reachable yet | NEW | Low | @@ -210,13 +211,14 @@ it, where in their nav, and how do they get there.* | PartnerRail | — | — (not routed) | Embedded rail | Not reachable yet | UPGRADE | Low | | System Sheet | — | — (not routed) | Reference (no surface) | Not reachable yet | NEW | Low | -## Shared (multi-workspace) — 18 canvases +## Shared (multi-workspace) — 19 canvases | Canvas | Route | Nav section | Surface | Entry point | Status | Confidence | | --- | --- | --- | --- | --- | --- | --- | | Account | `/account` · `/account/:section` · `/settings` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | | Auth and Onboarding | `/login` · `/register` · `/onboarding/chat` · `/onboarding/persona` · `/onboarding/founder\|investor\|partner` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | | Calendar | `/calendar` | — (no nav entry) | Full page | Deep link / in-page action | LIVE (built 2026-09-10) | Medium | +| Eadwyn Tickets · Eadwyn chat panels (T2, T3, D606) | `/studio` | — (not routed) | Full page (proposed) | Not reachable yet | CURRENT | Low | | Emails | — | — (not routed) | Templates (no surface) | Not reachable yet | UPGRADE | Low | | Events | `/events` · `/events/:slug` · `/my/events` · `/events/new` · `/events/:id/edit` · `/events/:id/manage` · `/invite/:token` | — (no nav entry) | Full page | Deep link / in-page action | UPGRADE | Medium | | Help Center | `/help` · `/help/:area/:guide` · `/help/tickets` · `/help/tickets/:id` · `/help/admin/*` · `/help/:id` · `/docs` · `/docs/admin/*` · `/support` | — (no nav entry) | Full page | Deep link / in-page action | CURRENT | Medium | @@ -258,9 +260,9 @@ it, where in their nav, and how do they get there.* | Confidence | Rows | Meaning | | --- | ---: | --- | -| High | 97 | Live route, reachable from that workspace’s sidebar. | +| High | 98 | Live route, reachable from that workspace’s sidebar. | | Medium | 45 | Live route, but no sidebar row for that role — reached by deep link or an in-page action. | -| Low | 35 | Not routed. Proposed route only. | +| Low | 36 | Not routed. Proposed route only. | A Medium row is not a defect. Detail pages (`/deals/:id`), print artefacts and wizards are reached from the surface above them by design; the brief’s diff --git a/documentation/architecture/ROUTE_MAP.md b/documentation/architecture/ROUTE_MAP.md index 3f113954d7..6236992263 100644 --- a/documentation/architecture/ROUTE_MAP.md +++ b/documentation/architecture/ROUTE_MAP.md @@ -175,6 +175,8 @@ does not fill it. | Canvas | Persona | Proposed route | Live route today | Backend it wires to | STATUS | Notes | | --- | --- | --- | --- | --- | --- | --- | | Ops Page · Advisor practice (A1–A5, D573) | advisor, founder, public | `/practice/deliverables`, `/practice/session-types`, `/practice/booking-links`, `/practice/access-log`, `/advisors/:uid`, `/b/*` | Practice Delivery/Sessions/Engagements sections, `AdvisorProfilePage`, `PublicAdvisorBookingPage` | `advisors.ts`, `advisor_grants.ts`, migration 394 | CURRENT | D573 wires the existing Ops A1–A5 screens to nullable due/work status, explicit included pricing, attributed booking counts, link toggles/opens, own access receipts and client-only acceptance. Public booking reuses A5 and the existing directory dialog; older facts remain Not recorded. | +| Eadwyn Tickets · Review queue (T1, T1b, D606) | super-admin | `/hq/review`, `/hq/review?tab=tasks` | `/hq/review` → `pages/hq/ReviewQueuePage.jsx` (`hqOnly`; the HQ sidebar's Support row lights, and HQ Support links to it) | `admin_ticket_reviews.ts` (D544: lists with `total`, tasks with their issue number), `community_tasks.ts` `GET /rewards` | CURRENT | Built to the owner's Eadwyn Tickets canvas (#1107, D606): the two tabs with their counts (the number waiting, withheld while a list is loading or unreadable), title search, Priority and Date filters, "1–25 of [N]" with a note when only the oldest 200 are loaded, the most pressing first, and Approve and Decline on the item they decide. A task carries the "Publish to" audience and "Approve · +N credits", N read from the credit rules. **Departures:** Approve asks for a reason too (the Worker records one of at least ten characters with every decision, D544); "Asked by" is a name and an email, because no page exists for one account; the page carries HQ's Worker AI rail like every HQ page, where the canvas draws none. H7's "Feature review queue →" (D604's departure) now links here, for the Super Admin. | +| Eadwyn Tickets · Eadwyn chat panels (T2, T3, D606) | shared | `/studio` (the Eadwyn chat) | every Studio home's `StudioInterview` → `PersonalAdvisor`: the panel's tabs Conversation, Tasks and My tickets (`components/advisor/EadwynPanels.jsx`) | `community_tasks.ts` (D545; D606 adds each approved suggestion's `reward` and `GET /rewards`), `tickets.ts`, `perks.ts` `GET /mine` | CURRENT | Built to the canvas (#1108, D606). **Tasks:** the approved community tasks with Start, the member's suggestions with Pending review, Approved · +N credits (what the grant paid, from the ledger) or Declined with the reason, older ones a page away, and "Suggest a task", which opens the ticket form on Task. **My tickets:** the balance (withheld, never 0, when unreadable), the five most recent tickets with type, priority, bounty or review, and what a fixed bug earns by tier, from the rules. On a desktop the tabs head the rail and Conversation keeps the interview widget; on a phone they sit above the card and Conversation is the chat. The chat's ticket form has no default type now. **Departures:** Start puts "Help me with …" in the composer, because no task page exists; a duplicate's original and a not-a-bug reason are in the ticket's replies, so the row links to the ticket; the amounts are by the tier a fix is graded at, not by priority. | | Partner Operator Canvas | partner | `/partner/operations/*` | `/studio`, `/spinout-lab`, `/needs`, `/partner/insights`, `/partner/operations/*`, `/services`, `/perks`, `/comarketing`, `/partner/office-hours`, `/network`, `/market-intel` | `partner_portal.ts`, `partners.ts`, `partnernet.ts`, `needs.ts`, `services.ts`, `comarketing.ts`, `market_intel.ts` | UPGRADE | Seven-row Partner shell is live: Home, Spin-Out Lab, Pipeline, Delivery, Offers, Network, Research. The headerless sidebar keeps existing deep links under their owning workspace. Pipeline, Delivery, Offers, Network, and Research share Partner-only amber identity, cyan source/grant seams, responsive dark-mode framing, and honest access guidance while retaining their existing live API data, actions, guards, and role branches. Home and Spin-Out Lab remain intentionally unchanged. D394: P2 Home is built as `pages/partner/PartnerHomeP2.jsx` over the existing Delivery and Pipeline reads, with the operating brief as the `home/brief` draft surface — NOT mounted; `/studio` stays `PartnerStudioHome` until the owner signs P2 off. D395: the Firm Settings row's successor is live — `PartnerFirmProfileCard` is mounted on `/company-settings` for partner sign-ins, above the no-company gate, and `/partner/operations/*` redirects to the canvas-built pages. **UPDATE 2026-10-06 (D564, #1168):** `/studio` for a partner is Partner Studio. `PartnerStudioHome` keeps its masthead, Eadwyn and the profile band, and mounts P2 (`PartnerHomeP2`, D394) as its body, signed off by the owner on #981. The old module grid is retired, and nothing on the page says Home. | | PartnerRail | chrome | — (component, not a route) | Partner workspace side rail | `assistant.ts`, `monitoring.ts` | UPGRADE | The Partner shell now carries the honest AI-assist, consent/screening, founder-grant, and source-provenance guidance rail. It does not invent spend, model, or action controls: those remain unavailable until a scoped Partner rail API exists. | | Perks & Products | shared (founder + partner) | `/perks` (new) | `/perks` → `PerksPage` (Wave 4) | — (none) for perks/credits; adjacent `products.ts`, `catalog.ts`, `admin_promos.ts`, `billing.ts` | NEW | Credit-priced partner perk marketplace: claim modal with credit debit + redemption code, My Perks + credit ledger, partner submit with live preview + review queue, listing analytics. Distinct from `/marketplace` (services) and `/plans-and-pricing` (Stripe storefront). No perk or credit-ledger table exists. **Wave 4 shipped** it: migration 186 (`perks`, `perk_claims`, `perk_credit_ledger`, `perk_views`), `routes/perks.ts`, and `/perks` role-branched inside one route — founders browse and claim, partners submit and see their own listing stats, admins review. A perk is priced one of three ways: `credits` debits the ledger, `tier` is gated by the SAME `userMeetsTier` the rest of the product uses rather than a second notion of entitlement, and `money` quotes `price_cents` the partner invoices offline. Balance is `SUM(delta)` and is never stored; a claim and its debit go in one `DB.batch`, and two unique indexes (`perk_claims(perk_id, user_id)`, `perk_credit_ledger(user_id, kind, source_ref)`) make a double-spend unreachable rather than unlikely. **A second credit ledger, deliberately:** `intro_credit_ledger` (migration 150) is a different unit — one intro credit buys one warm introduction, where the canvas prices perks at 150–3,200 — so sharing the pool would impose an exchange rate nobody set; and its `bucket`/`kind` CHECK clauses cannot be extended without rebuilding a live table, which an additive migration must not do. **Not built, and why:** no monthly plan allowance. How many credits Growth or Studio includes is a commercial term nobody has set, and inventing one would put a made-up price in the product — the same reason Pricing is blocked in Wave 3. Credits enter only through an admin grant today, and the response carries `allowance_configured` so the page can say "no allowance is set up" instead of "not enough credits", which are very different things to tell someone with a zero balance. The catalogue also starts empty: the partners named in the canvas (Northlake, Sablefin, Oakbridge…) are placeholders, and listing them would invent commercial relationships. | diff --git a/documentation/architecture/decisions/D544.md b/documentation/architecture/decisions/D544.md index 656922cdee..5b1a7426bd 100644 --- a/documentation/architecture/decisions/D544.md +++ b/documentation/architecture/decisions/D544.md @@ -6,7 +6,8 @@ that, once approved for an audience, is published to the community task list and pays its suggester. Every decision is recorded once, with a typed reason, whatever GitHub answers.** Issue #1107, slot S08, part of #1101. Migration 383. This entry covers the store and the API; the HQ page waits on its design -(`needs-design`). +(`needs-design`). **D606 builds the page** at `/hq/review`, to the owner's +Eadwyn Tickets canvas. **What was true on main.** - `POST /api/tickets` (`routes/tickets.ts`) filed a ticket with a type and diff --git a/documentation/architecture/decisions/D545.md b/documentation/architecture/decisions/D545.md index cb9d04bd9d..43b2047020 100644 --- a/documentation/architecture/decisions/D545.md +++ b/documentation/architecture/decisions/D545.md @@ -5,7 +5,8 @@ tasks for their persona and for everyone, and their own suggestions with their review state; a read that fails is a refusal, never an empty list.** Issue #1108, slot S08, part of #1101. No migration: the store is D544's `community_tasks` (migration 383). This entry covers the API; the Tasks panel -in `PersonalAdvisor.jsx` waits on its design (`needs-design`). +in `PersonalAdvisor.jsx` waits on its design (`needs-design`). **D606 builds the +panel** to the owner's Eadwyn Tickets canvas. **What changed.** - **`routes/community_tasks.ts`**, mounted at `/api/community-tasks`. It diff --git a/documentation/architecture/decisions/D604.md b/documentation/architecture/decisions/D604.md index ecf6001d6d..2cdd1f7be7 100644 --- a/documentation/architecture/decisions/D604.md +++ b/documentation/architecture/decisions/D604.md @@ -128,7 +128,9 @@ them, H3 to H7 of `design/canvases/integrated/Help Ticket Screen.dc.html`. mapped to a GitHub login through `ADMIN_GITHUB_LOGINS`. No roster of support staff exists to fill a picker. - **No "Feature review queue →" link on H7.** The queue's page waits on its - design (#1107, `needs-design`). The review itself is shown. + design (#1107, `needs-design`). The review itself is shown. D606 builds the + page and adds the link, for the Super Admin, on a request still awaiting + review. - **No logo bar.** The app shell carries the brand, as on H1. - **The "After submit" legend is not drawn.** The outcome it describes is shown on the ticket after submitting. diff --git a/documentation/architecture/decisions/D606.md b/documentation/architecture/decisions/D606.md new file mode 100644 index 0000000000..2a2d122bdc --- /dev/null +++ b/documentation/architecture/decisions/D606.md @@ -0,0 +1,171 @@ +## D606 — The Eadwyn Tickets canvas: the Super Admin's review queue, and the Eadwyn chat's Tasks and My tickets panels + +**Issues #1107 and #1108, slot S01, 2026-10-06.** Worker first. There is no +migration and one new route, `GET /api/community-tasks/rewards`. + +The owner approved the Eadwyn Tickets canvas on 2026-10-06 +(`https://claude.ai/artifact/7sLCsbcdBAqK8XuaYVtzcA`). It was drawn from the +prompt on #1107. It is a board, `Eadwyn Tickets.dc.html`, and the component +its artboards import, `Eadwyn Tickets Screen.dc.html`. Both are committed in +`design/canvases/integrated/`, since all four of its screens are built here: + +| Board | Screen | Built at | +| --- | --- | --- | +| T1 | HQ review queue · Feature requests | `/hq/review` | +| T1b | HQ review queue · Suggested tasks | `/hq/review?tab=tasks` | +| T2 | Eadwyn chat · Tasks panel | the chat on every Studio home | +| T3 | Eadwyn chat · My tickets | the chat on every Studio home | + +### What was true on main + +- **D544** (#1107) built the review store and its Super Admin routes. **D545** + (#1108) built the members' task list. Both pages waited on this design. +- **A list that could undercount.** The review lists returned the oldest 200 + waiting and a `count` of what came back, so a queue past the 200th would + show a short number. +- **No issue number on tasks.** A suggested task carried its GitHub link but + not its issue number, though a feature request carried both. +- **No paid amount on suggestions.** A member's suggestions said "approved" + without what the grant paid. +- **No member-readable rule amounts.** No member could read the reward + amounts the canvas states (`credit_reward_rules` is the Super Admin's). +- **A preset type in the chat.** The chat's ticket form preset the type to + `task`. D544 made the Worker refuse a missing type, and D604 gave the Help + Center's form no default; the chat's form still sent `task` for anyone who + did not change it. + +### The change: the Worker + +- **The review lists say how many are waiting.** + - `GET /api/admin/ticket-reviews/features` and `/tasks` return `total`, a + count of everything waiting, beside `count`, what the page holds. + - A task now carries `github_issue_number`. +- **Each approved suggestion carries `reward`.** This is the credits its grant + actually paid: the ledger row D544's approval writes, keyed `task:`, + read the way D542 reads a bounty. + - `revoked` says the grant was taken back. + - `credits: null` says nothing was paid (the rule was off, or the grant was + refused). + - `credits_unreadable` says the ledger could not be read, and the list is + still served. + - A pending or declined suggestion carries `null`. +- **`GET /api/community-tasks/rewards`** gives every signed-in member the four + amounts the panels state: `task_approved`, `bug_high`, `bug_medium` and + `bug_low`. Each is `{ credits, active }`, or `null` when the table has no + such rule. + - The feature rules are left out, since feature requests earn nothing + (#1102 Q2 A). + - A failed read is a 500 refusal (`rewards_unreadable`), never zeros. + +### The change: T1 and T1b, `/hq/review` (`pages/hq/ReviewQueuePage.jsx`) + +- **Who and where.** The page is the Super Admin's (`hqOnly`, as D544's routes + are). The sidebar's Support row lights on it, and HQ Support links to it. +- **The tabs:** + - Feature requests, and Suggested tasks (`?tab=tasks`); + - each tab counts the number waiting; + - the count is withheld while its list loads or cannot be read, never 0. +- **The toolbar:** + - a title search, and Priority and Date filters; + - "1–25 of [N]", where N is the number waiting, or the number matching once + a filter is on; + - when more wait than the 200 loaded, a line says so. +- **Order.** The most pressing come first, oldest first within a priority, as + the canvas draws them. +- **An item shows:** + - its title, priority and type, and its text; + - who asked, with the date; + - its GitHub issue, or "Not on the GitHub tracker". +- **A task also has:** + - the "Publish to" audience (D544's six, in the Worker's order); + - "Approve · +N credits", with N read from the credit rules. When the rule is + off, absent or unreadable, the button says only "Approve", and a line under + it says why. +- **Approve and Decline open the canvas's confirm panels on the item they + decide.** + - Decline says the user sees the reason. + - Neither panel sends until the reason is long enough. +- **After a decision,** the card shows what the route did, and the tab's + count drops: + - the GitHub label, or why there is none; + - whether the requester was told; + - for a task, the credits paid, or the refusal in the grant service's own + words. +- **A 409** says someone decided it first, and asks for a reload. +- **The rail.** The page carries HQ's Worker AI rail, like every HQ page. + +### The change: T2 and T3, the Eadwyn chat (`components/advisor/EadwynPanels.jsx`) + +- **Three tabs head the chat's panel:** Conversation, Tasks and My tickets. + - **Beside the chat,** on a desktop and in fullscreen, they head the rail. + Conversation keeps the rail it always had: the interview's sections and + queue. + - **On a phone,** they sit above the card. Conversation is the chat itself, + as the canvas hides the chat column there. +- **Tasks (T2):** + - the approved community tasks, with Start; + - the member's suggestions, each with its state: Pending review, + "Approved · +N credits" (what the grant paid), "credits taken back", or + Declined with the reason; + - older suggestions, one request away; + - "Suggest a task", which opens the ticket form on Task; + - "No tasks yet" only for an empty list; a list that could not be read says + so. +- **My tickets (T3):** + - the balance, from `/perks/mine`, linking to Perks. One that could not be + read is withheld, never 0. + - the five most recently active tickets, each with type, priority, and + bounty (D542's words) or review (D604's badge); + - a link to all tickets; + - what a fixed bug earns by tier, from the rules. +- **The chat's ticket form has no default type.** It opens on Task only from + "Suggest a task". Its submit stops, naming the field, while no type is + picked. + +### Where the build departs from the canvas, and why + +- **Approve asks for a reason.** The Worker records a typed reason of at least + ten characters with every decision, approvals included (D544). The approve + panel says the reason is recorded and not sent to the requester. +- **"Asked by" is a name and an email, not a link.** No page exists for one + account; the canvas's `/admin/accounts/[id]` does not exist. +- **Start puts the task to Eadwyn.** It writes "Help me with …" into the + composer, which the chat reads as a request for an explanation, and the + member sends it. The canvas's `/tasks/[id]` does not exist, and no store + records who started a task. +- **A duplicate's original and a not-a-bug reason are in the ticket's + replies.** The ticket list carries neither, so the row says so and links to + the ticket. Reading every ticket's GitHub thread for the list was not worth + one call per row. +- **The bounty note is by tier, not by priority.** The canvas says "Urgent and + High bugs earn +150"; the tier is set by whoever grades the fix (D542), not + by the ticket's priority. The amounts are the rules' today, never code's. +- **The review page carries the Worker AI rail,** which the canvas does not + draw, as every HQ page does. + +### Tests + +- **`cloudflare-worker/test/eadwyn_tickets_d606.test.ts` (7):** + - the lists' `total` past the 200 they return, with decided requests and + other types left out; + - each task's issue number; + - the lists stay the Super Admin's; + - each suggestion's reward: paid, taken back, unpaid, another member's grant + never read as the caller's, and an unreadable ledger; + - the rewards read, an edited and a switched-off rule, a missing rule as + null, a failed read refused, and signed out refused. +- **`frontend/test/eadwyn_tickets_d606.test.mjs` (24):** + - the reason floor against the Worker's constant, and the audiences against + its list; + - the filters, order and count line; + - the withheld counts; + - the reward states; + - the outcome sentences; + - every rendered state of the queue, the confirm panels and both panels; + - the route, the sidebar and the client; + - H7's link for the Super Admin only; + - the committed canvas. +- **`cloudflare-worker/test/ticket_type_required_d544.test.ts`:** now pins the + chat form's rule. +- **`frontend/test/profile_routing_fresh.test.mjs`:** counts the two new + ROUTE_MAP rows (137). diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e4c7a8bd01..f20d8a640a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -250,6 +250,8 @@ const HqPlatformTopologyPage = lazy(() => import('./pages/hq/PlatformTopologyPag const HqAnalyticsPage = lazy(() => import('./pages/hq/HqAnalyticsPage')); const HqSecurityPage = lazy(() => import('./pages/hq/SecurityPage')); const HqSupportPage = lazy(() => import('./pages/hq/HqSupportPage')); +// D606 (#1107) — the Super Admin's review queue, the Eadwyn Tickets canvas's T1 and T1b. +const ReviewQueuePage = lazy(() => import('./pages/hq/ReviewQueuePage')); const HqFundsPage = lazy(() => import('./pages/hq/HqFundsPage')); const KYCPage = lazy(() => import('./pages/KYCPage')); const TrustCenterPage = lazy(() => import('./pages/TrustCenterPage')); @@ -2647,6 +2649,7 @@ function AppInner() { ))} /> ))} /> ))} /> + ))} /> ))} /> )} /> {/* Task #102 — standalone Spin-Out Lab admin dashboard (same component diff --git a/frontend/src/components/advisor/EadwynPanels.jsx b/frontend/src/components/advisor/EadwynPanels.jsx new file mode 100644 index 0000000000..99f9861638 --- /dev/null +++ b/frontend/src/components/advisor/EadwynPanels.jsx @@ -0,0 +1,265 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { RefreshCw } from 'lucide-react'; +import { api } from '../../lib/api'; +import { communityTasksApi } from '../../lib/api/communityTasks.js'; +import { reportError } from '../../lib/log'; +import { PILL, TONE, dayOf, priorityChip, typeChip } from '../../lib/ticketScreens'; +import { bountyLine } from '../tickets/BountyBadge'; +import ReviewBadge from '../tickets/ReviewBadge'; +import { + balanceLine, bountyRatesNote, panelTickets, suggestNote, suggestionLine, taskLine, +} from '../../lib/eadwynTickets'; + +/** + * D606 (#1108) — the Eadwyn chat's panel, the Eadwyn Tickets canvas's T2 and + * T3 (`design/canvases/integrated/Eadwyn Tickets Screen.dc.html`, screens + * `chat-tasks` and `chat-tickets`). + * + * Conversation the chat itself on a phone; beside the chat, the rail it + * always had (the interview's sections and queue) + * Tasks the approved community tasks for the member's persona and + * their own suggestions, with each one's state (D545) + * My tickets the credit balance, and each ticket's bounty or review + * + * Each panel owns its reads, and a failed read says so: never "No tasks yet" + * for a list that could not be read, never 0 for a balance that could not be. + */ + +export const PANEL_TABS = [ + { id: 'conversation', label: 'Conversation' }, + { id: 'tasks', label: 'Tasks' }, + { id: 'tickets', label: 'My tickets' }, +]; + +/** The canvas's tab strip above the panel. */ +export function PanelTabs({ tab, onTab, className = '' }) { + return ( +
+ {PANEL_TABS.map((t) => { + const on = t.id === tab; + return ( + + ); + })} +
+ ); +} + +const SECTION = 'text-[11px] font-semibold uppercase tracking-[.08em] text-axal-muted dark:text-gray-400'; +const CARD = 'flex flex-col gap-1 rounded-[10px] border border-axal-hairline px-3 py-2.5 dark:border-gray-800'; + +function Chip({ text, tone }) { + return {text}; +} + +function PanelUnreadable({ text, onRetry, testId }) { + return ( +
+ Unreadable. {text}{' '} + {onRetry && ( + + )} +
+ ); +} + +/** A read in its three states, re-runnable. */ +function useRead(reader) { + const [read, setRead] = useState({ status: 'loading', data: null }); + const [n, setN] = useState(0); + useEffect(() => { + let live = true; + setRead((r) => ({ ...r, status: 'loading' })); + Promise.resolve().then(reader).then( + (data) => { if (live) setRead({ status: 'ready', data }); }, + (e) => { reportError('EadwynPanels:read', e); if (live) setRead({ status: 'unreadable', data: null }); }, + ); + return () => { live = false; }; + }, [n]); // eslint-disable-line react-hooks/exhaustive-deps + return [read, useCallback(() => setN((x) => x + 1), [])]; +} + +// --------------------------------------------------------------------------- +// T2 — Tasks +// --------------------------------------------------------------------------- + +/** The Tasks panel as it renders from its reads: pure, so each state is testable. */ +export function TasksPanelView({ list, rewards, older = [], olderNext = null, onSuggest, onStart, onRetry, onOlder }) { + const tasks = list.status === 'ready' ? (list.data?.tasks || []) : []; + const mine = list.status === 'ready' ? [...(list.data?.my_suggestions || []), ...older] : []; + const next = older.length ? olderNext : (list.data?.my_suggestions_next ?? null); + return ( +
+
+ Community tasks + +
+ + {list.status === 'loading' &&

Reading the task list…

} + {list.status === 'unreadable' && ( + + )} + {list.status === 'ready' && !tasks.length && ( +

No tasks yet. Approved suggestions appear here.

+ )} + {tasks.map((t) => { + const line = taskLine(t.body); + return ( +
+ {t.title} + {line && {line}} + +
+ ); + })} + + {list.status === 'ready' && ( + <> + My suggestions + {!mine.length &&

You have not suggested a task yet.

} + {mine.map((s) => { + const st = suggestionLine(s); + return ( +
+
+ {s.title} + {st && } +
+ {st?.reason && {st.reason}} + {s.status === 'declined' && !st?.reason && ( + No reason was recorded. + )} +
+ ); + })} + {next != null && ( + + )} + + )} + +

{suggestNote(rewards)}

+
+ ); +} + +export function TasksPanel({ onSuggest, onStart }) { + const [list, reload] = useRead(() => communityTasksApi.list()); + const [rewards] = useRead(() => communityTasksApi.rewards()); + const [older, setOlder] = useState({ rows: [], next: null }); + useEffect(() => { if (list.status === 'loading') setOlder({ rows: [], next: null }); }, [list.status]); + const onOlder = (before) => { + communityTasksApi.list(before).then( + (data) => setOlder((o) => ({ rows: [...o.rows, ...(data?.my_suggestions || [])], next: data?.my_suggestions_next ?? null })), + (e) => reportError('EadwynPanels:older', e), + ); + }; + return ( + + ); +} + +// --------------------------------------------------------------------------- +// T3 — My tickets +// --------------------------------------------------------------------------- + +// D542's bounty wording, in the canvas's palette (Under review grey, +// Confirmed violet, Fixed green, Not a bug and Duplicate grey). +const BOUNTY_TONE = { neutral: 'grey', info: 'violet', ok: 'green', warn: 'amber', muted: 'grey' }; + +/** The My tickets panel as it renders from its reads: pure, so each state is testable. */ +export function TicketsPanelView({ tickets, balance, rewards, onRetry }) { + const bal = balanceLine(balance); + const rows = tickets.status === 'ready' ? panelTickets(tickets.data) : []; + const rates = bountyRatesNote(rewards); + return ( +
+
+
+ {bal.state === 'ready' + ? {bal.text} + : {bal.text}} + US$1 each · spent on Perks +
+ Perks → +
+ + {tickets.status === 'loading' &&

Reading your tickets…

} + {tickets.status === 'unreadable' && ( + + )} + {tickets.status === 'ready' && !rows.length && ( +

You have not opened a ticket.

+ )} + {rows.map((t) => { + const type = typeChip(t.type); + const prio = priorityChip(t.priority); + const bounty = bountyLine(t.bounty); + const updated = dayOf(t.updated_at || t.created_at); + return ( +
+
+ {t.title} + {type && {type.text}} +
+
+ {prio && {prio.text}} + {bounty && } + +
+ {bounty?.hint && ( + + {bounty.hint}{' '} + Open the ticket → + + )} + #{t.id}{updated ? ` · updated ${updated}` : ''} +
+ ); + })} + {tickets.status === 'ready' && rows.length > 0 && ( + + All tickets → + + )} + {rates &&

{rates}

} +
+ ); +} + +export function TicketsPanel() { + const [tickets, reload] = useRead(() => api.listTickets()); + const [balance] = useRead(() => api.perksMine()); + const [rewards] = useRead(() => communityTasksApi.rewards()); + return ; +} diff --git a/frontend/src/components/advisor/PersonalAdvisor.jsx b/frontend/src/components/advisor/PersonalAdvisor.jsx index ce37661211..e1c240678a 100644 --- a/frontend/src/components/advisor/PersonalAdvisor.jsx +++ b/frontend/src/components/advisor/PersonalAdvisor.jsx @@ -61,6 +61,9 @@ import { predictTarget } from '../../lib/advisor/router'; import { useWebSocket } from '../../hooks/useWebSocket'; import AdvisorProgressWidget from './AdvisorProgressWidget'; import MyBugReports from '../tickets/MyBugReports'; +import { PanelTabs, TasksPanel, TicketsPanel } from './EadwynPanels'; +import { startPrompt } from '../../lib/eadwynTickets'; +import { TICKET_TYPES } from '../../lib/ticketScreens'; const STORAGE_KEY = 'advisor:state'; @@ -162,6 +165,11 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on // D324 — a Studio home's collapsed interview row reopens the chat with // the ticket form already open when its "Open a ticket" is pressed. const [ticketOpen, setTicketOpen] = useState(initialTicketOpen); + // D606 (#1108) — the Eadwyn Tickets canvas's panel: Conversation, Tasks and + // My tickets. "Suggest a task" opens the ticket form with Type set to Task; + // every other way in opens it with no type chosen (D544). + const [panelTab, setPanelTab] = useState('conversation'); + const [ticketPreset, setTicketPreset] = useState(null); // Task #2 (AR) — server-driven per-page / per-section progress // and Spin-Out week banner state. Refreshed after every answer @@ -744,6 +752,7 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on */ const handleTicketFiled = useCallback((t) => { setTicketOpen(false); + setTicketPreset(null); const title = t?.title ? `"${t.title}"` : 'Your ticket'; const status = t?.github_sync_status || null; const reason = String(t?.github_sync_error || '').trim(); @@ -770,6 +779,17 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on }]); }, [user?.role]); + // D606 — the panel's two actions. "Start" puts the task to Eadwyn as a + // "help me with" line in the composer, which opens an explanation when + // sent; nothing is sent until the member sends it. + const toggleTicket = useCallback(() => { setTicketPreset(null); setTicketOpen((v) => !v); }, []); + const closeTicket = useCallback(() => { setTicketOpen(false); setTicketPreset(null); }, []); + const suggestTask = useCallback(() => { setTicketPreset('task'); setTicketOpen(true); }, []); + const startTask = useCallback((task) => { + setInput(startPrompt(task)); + if (!isDesktop) setPanelTab('conversation'); + }, [isDesktop]); + // ---------- Render ------------------------------------------------------ if (!user) return null; // anonymous: nothing to advise on yet // Endpoint missing in this environment — render nothing so the @@ -811,9 +831,14 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on progressBumpToken={progressBumpToken} onPickQuestion={handlePickQuestion} ticketOpen={ticketOpen} - onToggleTicket={() => setTicketOpen((v) => !v)} + ticketPreset={ticketPreset} + onToggleTicket={toggleTicket} onTicketFiled={handleTicketFiled} - onCloseTicket={() => setTicketOpen(false)} + onCloseTicket={closeTicket} + panelTab={panelTab} + onPanelTab={setPanelTab} + onSuggestTask={suggestTask} + onStartTask={startTask} /> ); } @@ -831,13 +856,17 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on persona={persona} progress={progress} onMaximize={() => setViewMode('fullscreen')} - onOpenTicket={() => setTicketOpen((v) => !v)} + onOpenTicket={toggleTicket} ticketOpen={ticketOpen} /> {weekBanner && } {ticketOpen && ( - setTicketOpen(false)} /> + )} + {/* D606 — on a phone the panel's tabs sit above the card's body, and + Conversation is the chat itself (the canvas hides the chat column + below 760px and shows the panel). */} + {!isDesktop && }
{/* Chat column. On mobile we cap the height to ~60vh so the chat @@ -848,7 +877,7 @@ export default function PersonalAdvisor({ disablePersistedFullscreen = false, on min-h-[260px] lets it shrink) scrolls internally while the current-question line and composer stay pinned at the bottom, and no blank gap opens beneath the task panel as the chat grows. */} -
+
- - + Completed) stay visible as the chat transcript grows. + D606 — it is the panel's Conversation tab now, beside Tasks and + My tickets. */} + {isDesktop ? ( + + ) : panelTab !== 'conversation' && ( +
+ {panelTab === 'tasks' ? : } +
)}
@@ -955,7 +997,8 @@ function FullscreenView({ input, setInput, onSend, onSkip, busy, onPickOption, sectionStats, focusSection, pickFocus, pendingEvidence, labState, progressBumpToken, onPickQuestion, - ticketOpen, onToggleTicket, onTicketFiled, onCloseTicket, + ticketOpen, ticketPreset, onToggleTicket, onTicketFiled, onCloseTicket, + panelTab = 'conversation', onPanelTab, onSuggestTask, onStartTask, }) { useEscapeClose(onExit); const vvStyle = useVisualViewportStyle(); @@ -969,7 +1012,7 @@ function FullscreenView({ > {ticketOpen && ( - + )} {/* Responsive: single column on small screens (chat over a capped, scrollable progress section), two panes on large screens. */} @@ -1006,16 +1049,25 @@ function FullscreenView({ fullscreen so the progress widget is present at every width: a capped, independently-scrollable section stacked BELOW the chat on small screens, and a full-height side rail on large. */} -
@@ -1256,10 +1308,12 @@ function CtaButtons({ cta, onCtaClick }) { // POST /api/tickets endpoint via api.createTicket (no backend changes). // On success it calls onFiled(ticket) so the advisor can confirm in the // transcript with a link back to the Help Center. -function AdvisorTicketPanel({ onFiled, onClose }) { +function AdvisorTicketPanel({ onFiled, onClose, initialType = null }) { const [title, setTitle] = useState(''); const [priority, setPriority] = useState('medium'); - const [type, setType] = useState('task'); + // D606 — the type is a required choice with no default (D544, the H4 form's + // rule), except from "Suggest a task", which opens the form on Task. + const [type, setType] = useState(initialType || ''); const [description, setDescription] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1270,6 +1324,10 @@ function AdvisorTicketPanel({ onFiled, onClose }) { setError('Please enter a short summary for your ticket.'); return; } + if (!TICKET_TYPES.some((t) => t.id === type)) { + setError('Choose a type: Task, Bug or Feature.'); + return; + } setSubmitting(true); setError(null); try { @@ -1295,7 +1353,7 @@ function AdvisorTicketPanel({ onFiled, onClose }) {
- Open a support ticket + {initialType === 'task' ? 'Suggest a task' : 'Open a support ticket'}
diff --git a/frontend/src/pages/hq/HqSupportPage.jsx b/frontend/src/pages/hq/HqSupportPage.jsx index d77c9a9463..e674f3e29b 100644 --- a/frontend/src/pages/hq/HqSupportPage.jsx +++ b/frontend/src/pages/hq/HqSupportPage.jsx @@ -1139,6 +1139,12 @@ export default function HqSupportPage() { stands now, so the two ticket queues and the tickets outside them add up to HQ Home's Queue backlog. Open an escalation to answer it.

+ {/* D606 — the Super Admin's review queue sits under Support (the Eadwyn Tickets canvas lights this row). */} +

+ + Review queue: feature requests and suggested tasks → + +

diff --git a/frontend/src/pages/hq/ReviewQueuePage.jsx b/frontend/src/pages/hq/ReviewQueuePage.jsx new file mode 100644 index 0000000000..fb2bd93a23 --- /dev/null +++ b/frontend/src/pages/hq/ReviewQueuePage.jsx @@ -0,0 +1,457 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { ExternalLink, Inbox, RefreshCw } from 'lucide-react'; +import { communityTasksApi } from '../../lib/api/communityTasks.js'; +import { ticketReviewsApi } from '../../lib/api/ticketReviews.js'; +import { reportError } from '../../lib/log'; +import { RailFrame, WorkerRail } from '../../ui'; +import { dayOf, priorityChip, typeChip } from '../../lib/ticketScreens'; +import { + AUDIENCES, DATE_WINDOWS, NO_QUEUE_FILTERS, QUEUE_TABS, approveCopy, approveLabel, audienceLabel, + decisionOutcome, decisionRefusal, hasQueueFilters, queueView, reasonHint, reasonReady, rewardNote, + tabCount, tabFromSearch, taskReward, +} from '../../lib/eadwynTickets'; + +/** + * D606 (#1107) — HQ · Review queue, the Eadwyn Tickets canvas's T1 and T1b + * (`design/canvases/integrated/Eadwyn Tickets Screen.dc.html`, screens + * `queue-features` and `queue-tasks`). Super Admin only, as D544's routes are. + * + * /hq/review feature requests (T1) + * /hq/review?tab=tasks suggested tasks, with the audience picker (T1b) + * + * WHAT IT READS. Both lists at once, so each tab carries its count: the + * number waiting (`total`), withheld while loading and when a list cannot be + * read, never shown as 0. The reward on a task's Approve comes from the + * credit rules (`GET /api/community-tasks/rewards`), never from code: the + * Super Admin edits that amount. + * + * WHERE IT DEPARTS FROM THE CANVAS, and why (D606): + * - Approve asks for a reason too. The Worker records a typed reason of at + * least ten characters with every decision, approvals included (D544). + * - "Asked by" is a name and an email, not a link: there is no page for + * one account to link to. The Team directory is one row away in the + * sidebar. + * - The confirm panels open on the item they decide, rather than below + * the list, so a decision is never made on the wrong row. + */ + +function Chip({ chip }) { + if (!chip) return null; + return {chip.text}; +} + +const BTN = { + approve: 'flex-1 rounded-[9px] bg-axal-violet px-3 py-2 text-[12.5px] font-semibold text-white transition-colors hover:bg-axal-violet-deep disabled:cursor-not-allowed disabled:opacity-50', + decline: 'flex-1 rounded-[9px] border border-red-200 bg-white px-3 py-2 text-[12.5px] font-semibold text-red-700 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-900 dark:bg-gray-900 dark:text-red-300 dark:hover:bg-red-950/40', + quiet: 'rounded-[9px] border border-axal-hairline bg-white px-3 py-2 text-[12.5px] font-semibold text-axal-ink transition-colors hover:border-axal-violet/40 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100', + declineSolid: 'rounded-[9px] bg-red-700 px-3 py-2 text-[12.5px] font-semibold text-white transition-colors hover:bg-red-800 disabled:cursor-not-allowed disabled:opacity-50', + approveSolid: 'rounded-[9px] bg-axal-violet px-3 py-2 text-[12.5px] font-semibold text-white transition-colors hover:bg-axal-violet-deep disabled:cursor-not-allowed disabled:opacity-50', +}; +const FIELD = 'w-full rounded-[9px] border border-axal-hairline bg-white px-3 py-2 text-[13px] text-axal-ink placeholder:text-axal-faint focus:outline-none focus:ring-2 focus:ring-axal-violet/40 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100'; +const SELECT = 'rounded-full border border-axal-hairline bg-white px-3 py-1.5 text-[12px] font-medium text-axal-ink focus:outline-none focus:ring-2 focus:ring-axal-violet/40 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100'; + +/** The canvas's two confirm panels, on the item they decide. */ +export function DecisionPanel({ kind, tab, item, audience, reward, busy, error, onCancel, onConfirm }) { + const [reason, setReason] = useState(''); + const approving = kind === 'approve'; + const hint = reasonHint(reason); + const ready = reasonReady(reason) && !busy; + const id = `review-reason-${tab}-${item.id ?? item.ticket_id}`; + return ( +
+
+ {approving ? 'Approve · confirm' : 'Decline · reason required'} +
+
+ {approving ? ( + <> + {approveCopy(tab, item, { audience, reward })} + {tab === 'tasks' && ( + + Audience: {audienceLabel(audience)} + {reward?.state === 'on' ? ` · suggester ${item.asked_by?.name || 'with no name on record'} receives +${reward.credits} credits` : ''} + + )} + + ) : ( + The user sees this reason on their ticket. + )} + +