diff --git a/TODO.md b/TODO.md index 6fbb1aa..8029732 100644 --- a/TODO.md +++ b/TODO.md @@ -122,8 +122,310 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K - Add examples for fields that expect URLs, model names, provider names, paths, selectors, or structured values. - Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity. - Review localization/translation keys if present so simplified wording remains consistent across languages. + +## Core Feedback System + +First implementation pass completed 2026-06-18. Remaining feedback work is mainly optional screenshot capture/storage, richer duplicate detection, date/plugin/area admin filters, issue creation, OKF correction conversion, and deeper attachment/DOM snapshot handling. Keep the detailed checklist below as the remaining reference for hardening and follow-up passes. + +- Implement a general core feedback system available to logged-in users only. +- Add a persistent feedback entry point somewhere logical in the UI. +- Add `/feedback` under the Community navbar section for logged-in users. +- Add `/admin/feedback` as the admin review entry point. +- Ensure the feedback system is core-level functionality and can cover any area, scope, feature, page, plugin, or UI element. +- Keep the AI Improvement Center separate from this system; it should continue handling AI reply quality feedback. +- Add a `wrong tool` classification to the AI Improvement Center for cases where the AI used the wrong tool, action, integration, or capability. + +### Feedback Categories and Scope + +- Use generic feedback categories based on issue type rather than feature-specific categories. +- Suggested categories: + - bug + - confusing wording + - broken interaction + - visual/layout issue + - accessibility issue + - missing feature + - improvement suggestion + - performance issue + - permission/access issue + - unexpected behavior + - other +- Allow feedback to target: + - the whole page + - a specific clicked UI element + - the current feature/page + - a plugin + - a broad system area + - other/custom scope +- Encourage users to submit one feedback item per issue. +- Add helper text explaining that small, specific feedback reports are preferred over broad combined reports. + +### Feedback Submission UI + +- Add a feedback modal for creating feedback. +- The modal should be accessible from the persistent feedback button and from the custom context menu. +- Require logged-in user identity for all feedback submissions. +- Allow the user to enter: + - short summary/title + - category + - severity/priority + - scope/target + - detailed description + - optional steps to reproduce + - optional expected behavior + - optional actual behavior +- Support severity/priority values such as: + - minor + - confusing + - broken + - urgent + - security/sensitive + - suggestion +- Pre-fill feedback scope based on the current page or clicked element when available. +- Allow users to change the auto-detected scope before submitting. +- Show clear confirmation after feedback is submitted. + +### Site-Wide Custom Context Menu + +- Replace the standard browser right-click context menu across Lumi with a custom site-wide context menu. +- Include the following actions: + - Back + - Forward + - Copy + - Cut + - Paste + - Link to here + - Hard reload + - Feedback +- Ensure context menu actions respect browser permissions and limitations. +- Ensure copy, cut, and paste only appear or work where appropriate. +- Implement “Link to here” by creating a URL to the current page and, when possible, the clicked element or section. +- Implement “Hard reload” as a cache-bypassing reload comparable to a developer hard refresh where possible. +- Add the Feedback action to open the feedback modal with the clicked element as the target. +- Preserve keyboard accessibility and provide fallback behavior if custom context menu features are unavailable in a browser. + +### Element-Targeted Feedback + +- When feedback is opened from the context menu, capture the clicked element as the feedback target. +- Store safe metadata about the clicked element, such as: + - selector or generated stable path + - element tag + - visible text snippet + - aria-label/title/role when available + - nearest form label or heading + - page URL + - page title + - viewport size +- Do not store sensitive field values from passwords, tokens, secrets, or private inputs. +- Allow users to choose whether feedback is about the clicked element, the whole page, the current feature, a plugin, or another scope. +- Highlight the selected target while the feedback modal is open when practical. +- Remove the highlight when the modal closes. + +### Optional Screenshot Support + +- Add optional browser-generated screenshot support. +- Screenshots must be opt-in by the submitting user. +- Do not require screenshots for feedback submission. +- Support full-page screenshots where technically possible. +- Support cropped/section-only screenshots where technically possible. +- Temporarily hide feedback buttons, feedback modals, context menus, and feedback overlays while capturing screenshots. +- Restore hidden feedback UI immediately after capture. +- Store screenshots only when explicitly attached by the user. +- Clearly warn users not to include sensitive information in screenshots. +- Allow users to remove an attached screenshot before submitting. + +### Optional Diagnostic Data + +- Automatically attach basic non-sensitive context: + - user ID + - current URL + - page title + - timestamp + - selected scope + - target metadata if element-targeted +- Make browser/user agent and screenshot/DOM snapshot optional opt-in fields. +- If DOM snapshot support is added, sanitize it before storage. +- Do not capture sensitive form values, passwords, tokens, secrets, private messages, or hidden data. +- Clearly label optional diagnostic data so users understand what they are submitting. + +### Feedback Visibility for Users + +- On `/feedback`, show logged-in users a general list of current and past feedback that has not been deleted. +- Public/general feedback list should only show basic non-identifying information: + - summary + - category + - general scope + - status + - created date + - last updated date +- Do not expose submitter identity in the general feedback list. +- Allow users to see their own feedback in detail. +- Make it easy to distinguish the current user’s own feedback from general feedback. +- Consider a separate “My feedback” section or a clear badge/label in the shared list. +- Allow users to add comments to their own feedback when follow-up is needed. +- Allow users to see admin replies that are visible to the submitter. + +### User Feedback Notifications + +- Add per-user feedback notification badges. +- Show a green badge/sphere with the number of the user’s feedback items solved since they last opened the feedback center. +- Show a red badge/sphere with the number of the user’s feedback items marked as needing more context. +- Show a grey badge/sphere with the number of the user’s feedback items marked as declined, rejected, duplicate, won’t fix, not planned, or otherwise not being worked on. +- Reset or update badge counts when the user opens the feedback center and views the relevant items. +- Ensure notification badges only reflect the logged-in user’s own feedback. + +### Feedback Statuses + +- Support feedback statuses such as: + - new + - reviewed + - accepted + - planned + - in progress + - fixed + - solved + - needs more context + - duplicate + - rejected + - not planned + - won’t fix + - archived + - deleted +- Allow admins to change status from the admin review UI. +- Store status history with timestamps and actor information. +- Show user-friendly status names and explanations on `/feedback`. + +### Replies, Comments, and Work Notes + +- Allow admins to reply to feedback. +- Allow submitters to comment on their own feedback. +- Allow admins to add private internal work notes that are not visible to normal users. +- Allow admins to mark feedback as “needs more context”. +- When feedback is marked “needs more context”, notify the submitter through the red feedback badge. +- Allow the submitter to add additional context/comments after the request. +- Distinguish clearly between public/admin replies, submitter comments, and private admin work notes. + +### Admin Feedback Review + +- Add `/admin/feedback` for admin feedback review. +- Admins should be able to view full feedback details, including: + - submitter + - summary + - description + - category + - severity + - scope + - target metadata + - current URL + - browser/user agent if submitted + - screenshot if submitted + - DOM snapshot if submitted + - comments/replies + - work notes + - status history +- Add filters for: + - status + - category + - severity + - scope + - plugin/area + - submitter + - date + - needs admin action +- Add sorting by newest, oldest, severity, status, and last activity. +- Add admin actions: + - change status + - assign category + - change severity + - reply to submitter + - add internal work note + - request more context + - mark duplicate + - archive + - delete + - convert to TODO + - convert to issue + - convert to OKF correction where relevant + - link to existing TODO/issue/correction + +### TODO and Issue Conversion + +- Allow admins to convert feedback into a TODO entry. +- Support injecting generated TODO entries into `TODO.md`. +- Preserve the existing TODO format when injecting new entries. +- Allow admins to edit the generated TODO text before saving. +- Add enough context to generated TODO items to be useful for later Codex work. +- Allow admins to convert feedback into a GitHub/Gitea issue if configured. +- Allow admins to link feedback to manually created TODOs or issues. +- Store links between feedback items and generated TODOs/issues. +- Do not automatically create TODOs or issues without admin confirmation. + +### OKF and AI Improvement Integration + +- Keep this feedback system separate from AI reply feedback. +- Do not use this feedback system to rate individual AI replies. +- Use the AI Improvement Center for AI answer quality, wrong answers, bad context, and correction review. +- Add `wrong tool` as a classification in the AI Improvement Center. +- Allow core feedback to cover AI features, AI UI, AI configuration, and AI workflow problems. +- Allow admins to convert relevant core feedback into OKF corrections only when the feedback concerns documentation, feature behavior, terminology, or community/system knowledge. +- Do not automatically modify OKF based on general feedback. + +### Rate Limits and Abuse Prevention + +- Add moderate rate limits for feedback submissions. +- Rate limits should prevent spam without blocking normal use. +- Encourage one feedback item per issue. +- Add validation to prevent empty or extremely vague feedback. +- Add optional duplicate detection based on similar title, scope, and page. +- If similar existing feedback exists, suggest adding a comment or upvote/support instead of creating a duplicate. +- Allow admins to delete or archive abusive/spam feedback. + +### Data Storage and Preservation + +- Store feedback data in SQLite or the existing app storage system. +- Store screenshots and optional attachments in a protected feedback uploads/storage directory. +- Preserve feedback data across repo updates, ZIP updates, migrations, and recovery operations. +- Include feedback data in the protected user-data list for backups and update preservation. +- Never overwrite feedback data during core/plugin updates. +- Include feedback data in backup/restore planning. +- Store enough metadata for auditability: + - created_at + - updated_at + - submitter_id + - assigned_admin_id if used + - status history + - comments/replies + - linked TODOs/issues/corrections + +### Privacy and Safety + +- Do not expose submitter identity in the general feedback list. +- Only admins should see submitter identity and full diagnostic details. +- Users should only see full detail for their own feedback. +- Sanitize all submitted HTML/text before rendering. +- Avoid capturing sensitive form values. +- Make optional diagnostic capture transparent to users. +- Add admin controls for deleting sensitive feedback, screenshots, or diagnostic data. +- Ensure permissions are enforced server-side, not only in the UI. + +### Acceptance Criteria + +- Logged-in users can submit general feedback from a persistent UI entry point. +- Logged-in users can submit targeted feedback from the custom right-click context menu. +- Right-click context menu includes Back, Forward, Copy, Cut, Paste, Link to here, Hard reload, and Feedback. +- Feedback modal can target the whole page, clicked element, current feature/page, plugin, broad system area, or custom scope. +- Users can optionally attach browser-generated screenshots while feedback UI is hidden during capture. +- `/feedback` appears under the Community navbar section for logged-in users. +- `/feedback` shows general non-identifying feedback summaries and detailed feedback for the current user’s own submissions. +- Users see per-user notification badges for solved, needs-context, and not-worked-on feedback. +- Admins can review and manage feedback at `/admin/feedback`. +- Admins can reply, add work notes, request more context, change statuses, and convert feedback to TODOs/issues/corrections. +- Feedback data is preserved across updates and included in protected user data. +- AI reply feedback remains handled by the AI Improvement Center, with an added `wrong tool` classification. + ## Done +- 2026-06-18: Added a core feedback system first pass on `experimental-feedback-system`: SQLite feedback entries/comments/status history/view state, logged-in `/feedback`, admin `/admin/feedback`, persistent feedback modal, site-wide custom context menu with element-targeted feedback, per-user notification badges, admin replies/work notes/status changes/TODO conversion, and AI Improvement Center `wrong_tool_usage` confirmed present. - 2026-06-17: Added Economy cleanup for fallback-name artifact users such as `SammyCat-2`, merging them into matching real platform users and making Economy stats tolerate missing/mid-migration tables; bumped Economy Framework to v0.2.9. - 2026-06-17: Fixed update metadata for renamed Economy plugins so legacy installed rows are folded into canonical `economy-*` plugin update rows instead of appearing as separate installs; bumped core to v0.1.8. - 2026-06-17: Renamed all remaining Economy internals from the old misspelled IDs/paths/tables to `economy-*`, added startup migration for legacy plugin rows, settings, command usage IDs, tables, uploads, asset paths, old URLs, and bumped core/plugin patch versions. diff --git a/package-lock.json b/package-lock.json index 2f67b90..ae9cbf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index af38cd5..cc39400 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "commonjs", "scripts": { diff --git a/scripts/verify-feedback-system.js b/scripts/verify-feedback-system.js new file mode 100644 index 0000000..ab80dc4 --- /dev/null +++ b/scripts/verify-feedback-system.js @@ -0,0 +1,132 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const ejs = require("ejs"); + +const root = path.join(__dirname, ".."); +const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-feedback-test-")); +const serviceDir = path.join(sandbox, "src", "services"); + +fs.mkdirSync(serviceDir, { recursive: true }); +for (const file of ["db.js", "feedback.js"]) { + fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file)); +} + +let database = null; +try { + database = require(path.join(serviceDir, "db.js")); + database.migrate(); + const feedback = require(path.join(serviceDir, "feedback.js")); + const db = database.db; + const now = Date.now(); + db.prepare( + "INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)" + ).run("user-1", "FeedbackUser", now, now); + db.prepare( + "INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)" + ).run("admin-1", "AdminUser", now, now); + + const entry = feedback.createFeedback({ + summary: "Save button fails", + category: "broken_interaction", + severity: "broken", + scope_type: "element", + scope_label: "Save settings button", + description: "Clicking the save button does not apply the changes.", + steps_to_reproduce: "Open settings, change a field, click save.", + expected_behavior: "The settings should be saved.", + actual_behavior: "The page reloads without saving.", + current_url: "http://localhost/admin/settings", + page_title: "Settings", + target_metadata: { + selector: "#save-settings", + tag: "button", + text: "Save settings", + secret: "must not persist" + }, + diagnostics: { + user_agent: "verification-agent", + viewport: "1200x800", + hidden: "must not persist" + } + }, { id: "user-1" }); + + assert.equal(entry.status, "new"); + assert.equal(entry.target_metadata.secret, undefined); + assert.equal(entry.diagnostics.hidden, undefined); + assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true); + assert.equal(feedback.listMyFeedback("user-1").length, 1); + + feedback.addSubmitterComment(entry.id, "This also happens after a hard reload.", { id: "user-1" }); + const withComment = feedback.getFeedbackForSubmitter(entry.id, "user-1"); + assert.equal(withComment.comments.length, 1); + assert.equal(withComment.comments[0].kind, "submitter_comment"); + + feedback.markFeedbackViewed("user-1"); + feedback.adminUpdateFeedback(entry.id, { + status: "needs_more_context", + category: "bug", + severity: "urgent", + admin_reply: "Can you add which browser this happened in?", + work_note: "Likely settings form regression.", + status_note: "Need browser detail." + }, { id: "admin-1" }); + const notices = feedback.notificationSummary("user-1"); + assert.equal(notices.needs_context, 1); + const adminEntry = feedback.getFeedbackForAdmin(entry.id); + assert(adminEntry.comments.some((comment) => comment.kind === "admin_reply")); + assert(adminEntry.comments.some((comment) => comment.kind === "work_note")); + const submitterEntry = feedback.getFeedbackForSubmitter(entry.id, "user-1"); + assert.equal(submitterEntry.comments.some((comment) => comment.kind === "work_note"), false); + + const todoPath = path.join(sandbox, "TODO.md"); + fs.writeFileSync(todoPath, "# TODO\n\n## Done\n"); + feedback.convertFeedbackToTodo(entry.id, "Fix feedback verification issue.", { id: "admin-1" }, todoPath); + assert(fs.readFileSync(todoPath, "utf8").includes("## Feedback-Derived TODOs")); + assert(fs.readFileSync(todoPath, "utf8").includes("Fix feedback verification issue.")); + + const feedbackView = path.join(root, "src", "web", "views", "feedback.ejs"); + const adminView = path.join(root, "src", "web", "views", "admin-feedback.ejs"); + const commonLocals = { + siteTitle: "Lumi Bot", + assetVersion: "verify", + theme: null, + botAvatar: null, + navSections: [], + user: { id: "user-1", username: "FeedbackUser" }, + userAvatar: null, + userInitial: "F", + platformLogins: [], + platformLinks: [], + platforms: [], + flash: null, + softError: null, + feedbackNotifications: feedback.notificationSummary("user-1"), + feedbackOptions: feedback.feedbackOptions() + }; + const userRendered = ejs.render(fs.readFileSync(feedbackView, "utf8"), { + ...commonLocals, + title: "Feedback", + feedbackList: feedback.listPublicFeedback({ userId: "user-1" }), + myFeedback: feedback.listMyFeedback("user-1"), + selectedFeedback: feedback.getFeedbackForSubmitter(entry.id, "user-1") + }, { filename: feedbackView }); + assert(userRendered.includes("My feedback")); + assert(userRendered.includes("Admin reply")); + + const adminRendered = ejs.render(fs.readFileSync(adminView, "utf8"), { + ...commonLocals, + user: { id: "admin-1", username: "AdminUser", isAdmin: true }, + userInitial: "A", + title: "Feedback review", + feedbackItems: feedback.listFeedbackForAdmin({}), + filters: { status: "", category: "", severity: "", scope: "", submitter: "", needs_action: "", sort: "last_activity" } + }, { filename: adminView }); + assert(adminRendered.includes("Feedback queue")); + assert(adminRendered.includes("Convert to TODO")); + + console.log("Core feedback system verification passed."); +} finally { + database?.db.close(); + fs.rmSync(sandbox, { recursive: true, force: true }); +} diff --git a/src/services/db.js b/src/services/db.js index 8de5e1b..fc2decf 100644 --- a/src/services/db.js +++ b/src/services/db.js @@ -149,6 +149,66 @@ function migrate() { ); CREATE INDEX IF NOT EXISTS logs_created_at_idx ON logs (created_at); + + CREATE TABLE IF NOT EXISTS feedback_entries ( + id TEXT PRIMARY KEY, + submitter_id TEXT NOT NULL, + summary TEXT NOT NULL, + category TEXT NOT NULL, + severity TEXT NOT NULL, + scope_type TEXT NOT NULL, + scope_label TEXT, + target_metadata_json TEXT NOT NULL DEFAULT '{}', + current_url TEXT, + page_title TEXT, + description TEXT NOT NULL, + steps_to_reproduce TEXT, + expected_behavior TEXT, + actual_behavior TEXT, + diagnostics_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'new', + admin_reply TEXT, + assigned_admin_id TEXT, + linked_todo TEXT, + linked_issue TEXT, + linked_correction TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_activity_at INTEGER NOT NULL, + deleted_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS feedback_entries_submitter_idx ON feedback_entries (submitter_id); + CREATE INDEX IF NOT EXISTS feedback_entries_status_idx ON feedback_entries (status); + CREATE INDEX IF NOT EXISTS feedback_entries_last_activity_idx ON feedback_entries (last_activity_at); + + CREATE TABLE IF NOT EXISTS feedback_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feedback_id TEXT NOT NULL, + actor_id TEXT NOT NULL, + kind TEXT NOT NULL, + body TEXT NOT NULL, + visible_to_submitter INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS feedback_comments_feedback_idx ON feedback_comments (feedback_id, created_at); + + CREATE TABLE IF NOT EXISTS feedback_status_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feedback_id TEXT NOT NULL, + status TEXT NOT NULL, + actor_id TEXT, + note TEXT, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS feedback_status_history_feedback_idx ON feedback_status_history (feedback_id, created_at); + + CREATE TABLE IF NOT EXISTS feedback_view_state ( + user_id TEXT PRIMARY KEY, + last_seen_at INTEGER NOT NULL + ); `); const columns = db diff --git a/src/services/feedback.js b/src/services/feedback.js new file mode 100644 index 0000000..f38e41b --- /dev/null +++ b/src/services/feedback.js @@ -0,0 +1,605 @@ +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const { db } = require("./db"); + +const FEEDBACK_CATEGORIES = Object.freeze([ + "bug", + "confusing_wording", + "broken_interaction", + "visual_layout_issue", + "accessibility_issue", + "missing_feature", + "improvement_suggestion", + "performance_issue", + "permission_access_issue", + "unexpected_behavior", + "other" +]); + +const FEEDBACK_SEVERITIES = Object.freeze([ + "minor", + "confusing", + "broken", + "urgent", + "security_sensitive", + "suggestion" +]); + +const FEEDBACK_SCOPE_TYPES = Object.freeze([ + "page", + "element", + "feature", + "plugin", + "system", + "other" +]); + +const FEEDBACK_STATUSES = Object.freeze([ + "new", + "reviewed", + "accepted", + "planned", + "in_progress", + "fixed", + "solved", + "needs_more_context", + "duplicate", + "rejected", + "not_planned", + "wont_fix", + "archived", + "deleted" +]); + +const USER_VISIBLE_STATUSES = new Set(FEEDBACK_STATUSES.filter((status) => status !== "deleted")); +const SOLVED_STATUSES = new Set(["fixed", "solved"]); +const NEEDS_CONTEXT_STATUSES = new Set(["needs_more_context"]); +const NOT_WORKING_STATUSES = new Set(["duplicate", "rejected", "not_planned", "wont_fix"]); +const RATE_LIMIT = { max: 5, windowMs: 10 * 60 * 1000 }; + +const CATEGORY_LABELS = Object.freeze({ + bug: "Bug", + confusing_wording: "Confusing wording", + broken_interaction: "Broken interaction", + visual_layout_issue: "Visual/layout issue", + accessibility_issue: "Accessibility issue", + missing_feature: "Missing feature", + improvement_suggestion: "Improvement suggestion", + performance_issue: "Performance issue", + permission_access_issue: "Permission/access issue", + unexpected_behavior: "Unexpected behavior", + other: "Other" +}); + +const SEVERITY_LABELS = Object.freeze({ + minor: "Minor", + confusing: "Confusing", + broken: "Broken", + urgent: "Urgent", + security_sensitive: "Security/sensitive", + suggestion: "Suggestion" +}); + +const SCOPE_LABELS = Object.freeze({ + page: "Whole page", + element: "Clicked element", + feature: "Current feature/page", + plugin: "Plugin", + system: "System area", + other: "Other" +}); + +const STATUS_LABELS = Object.freeze({ + new: "New", + reviewed: "Reviewed", + accepted: "Accepted", + planned: "Planned", + in_progress: "In progress", + fixed: "Fixed", + solved: "Solved", + needs_more_context: "Needs more context", + duplicate: "Duplicate", + rejected: "Rejected", + not_planned: "Not planned", + wont_fix: "Won't fix", + archived: "Archived", + deleted: "Deleted" +}); + +const STATUS_HELP = Object.freeze({ + new: "Submitted and waiting for review.", + reviewed: "Seen by an administrator.", + accepted: "Accepted as valid feedback.", + planned: "Planned for a future pass.", + in_progress: "Being worked on.", + fixed: "A fix has been made.", + solved: "Resolved and no longer needs action.", + needs_more_context: "The submitter needs to add more detail.", + duplicate: "Covered by another feedback item.", + rejected: "Rejected after review.", + not_planned: "Not planned for implementation.", + wont_fix: "Reviewed, but will not be changed.", + archived: "Archived for record keeping.", + deleted: "Deleted by an administrator." +}); + +function createFeedback(input, actor) { + if (!actor?.id) { + throw new Error("Feedback requires a logged-in user."); + } + enforceRateLimit(actor.id); + const now = Date.now(); + const entry = normalizeFeedbackInput(input); + const id = crypto.randomUUID(); + db.transaction(() => { + db.prepare( + "INSERT INTO feedback_entries " + + "(id, submitter_id, summary, category, severity, scope_type, scope_label, target_metadata_json, current_url, page_title, description, steps_to_reproduce, expected_behavior, actual_behavior, diagnostics_json, status, created_at, updated_at, last_activity_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)" + ).run( + id, + actor.id, + entry.summary, + entry.category, + entry.severity, + entry.scope_type, + entry.scope_label, + JSON.stringify(entry.target_metadata), + entry.current_url, + entry.page_title, + entry.description, + entry.steps_to_reproduce, + entry.expected_behavior, + entry.actual_behavior, + JSON.stringify(entry.diagnostics), + now, + now, + now + ); + addStatusHistory(id, "new", actor.id, "Submitted", now); + })(); + return getFeedbackForAdmin(id); +} + +function listPublicFeedback({ userId, limit = 100 } = {}) { + const rows = db + .prepare( + "SELECT id, submitter_id, summary, category, severity, scope_type, scope_label, status, created_at, updated_at, last_activity_at " + + "FROM feedback_entries WHERE deleted_at IS NULL AND status != 'deleted' " + + "ORDER BY last_activity_at DESC LIMIT ?" + ) + .all(limit) + .map((row) => ({ + ...decorateLabels(row), + is_mine: userId ? row.submitter_id === userId : false, + submitter_id: undefined + })); + return rows; +} + +function listMyFeedback(userId) { + if (!userId) return []; + return db + .prepare( + "SELECT * FROM feedback_entries WHERE submitter_id = ? AND deleted_at IS NULL AND status != 'deleted' ORDER BY last_activity_at DESC" + ) + .all(userId) + .map((row) => hydrateFeedback(row, { admin: false })); +} + +function getFeedbackForSubmitter(id, userId) { + const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id); + if (!row || row.deleted_at || row.status === "deleted" || row.submitter_id !== userId) { + return null; + } + return hydrateFeedback(row, { admin: false }); +} + +function getFeedbackForAdmin(id) { + const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id); + return row ? hydrateFeedback(row, { admin: true }) : null; +} + +function listFeedbackForAdmin(filters = {}) { + const where = []; + const params = []; + if (filters.status && FEEDBACK_STATUSES.includes(filters.status)) { + where.push("feedback_entries.status = ?"); + params.push(filters.status); + } else { + where.push("feedback_entries.status != 'deleted'"); + where.push("feedback_entries.deleted_at IS NULL"); + } + if (filters.category && FEEDBACK_CATEGORIES.includes(filters.category)) { + where.push("feedback_entries.category = ?"); + params.push(filters.category); + } + if (filters.severity && FEEDBACK_SEVERITIES.includes(filters.severity)) { + where.push("feedback_entries.severity = ?"); + params.push(filters.severity); + } + if (filters.scope && FEEDBACK_SCOPE_TYPES.includes(filters.scope)) { + where.push("feedback_entries.scope_type = ?"); + params.push(filters.scope); + } + if (filters.submitter) { + where.push( + "(feedback_entries.submitter_id = ? OR lower(user_profiles.internal_username) LIKE lower(?))" + ); + params.push(filters.submitter, `%${filters.submitter}%`); + } + if (filters.needs_action === "1") { + where.push("feedback_entries.status IN ('new', 'needs_more_context')"); + } + const order = { + oldest: "feedback_entries.created_at ASC", + severity: severityOrderSql(), + status: "feedback_entries.status ASC, feedback_entries.last_activity_at DESC", + last_activity: "feedback_entries.last_activity_at DESC", + newest: "feedback_entries.created_at DESC" + }[filters.sort || "last_activity"]; + return db + .prepare( + "SELECT feedback_entries.*, user_profiles.internal_username AS submitter_name " + + "FROM feedback_entries LEFT JOIN user_profiles ON user_profiles.id = feedback_entries.submitter_id " + + `WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT 250` + ) + .all(...params) + .map((row) => hydrateFeedback(row, { admin: true })); +} + +function addSubmitterComment(id, body, actor) { + const entry = getFeedbackForSubmitter(id, actor?.id); + if (!entry) { + throw new Error("Feedback item was not found."); + } + const comment = cleanText(body, 4000); + if (comment.length < 3) { + throw new Error("Comment is too short."); + } + const now = Date.now(); + db.transaction(() => { + db.prepare( + "INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'submitter_comment', ?, 1, ?)" + ).run(id, actor.id, comment, now); + touchFeedback(id, now); + })(); +} + +function adminUpdateFeedback(id, input, actor) { + const current = getFeedbackForAdmin(id); + if (!current) { + throw new Error("Feedback item was not found."); + } + const now = Date.now(); + const nextCategory = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : current.category; + const nextSeverity = FEEDBACK_SEVERITIES.includes(input.severity) ? input.severity : current.severity; + const nextStatus = FEEDBACK_STATUSES.includes(input.status) ? input.status : current.status; + const adminReply = cleanText(input.admin_reply, 6000); + const workNote = cleanText(input.work_note, 6000); + const statusNote = cleanText(input.status_note, 1000); + const linkedIssue = cleanText(input.linked_issue, 1000); + const linkedCorrection = cleanText(input.linked_correction, 1000); + db.transaction(() => { + db.prepare( + "UPDATE feedback_entries SET category = ?, severity = ?, status = ?, admin_reply = ?, linked_issue = ?, linked_correction = ?, updated_at = ?, last_activity_at = ?, deleted_at = ? WHERE id = ?" + ).run( + nextCategory, + nextSeverity, + nextStatus, + adminReply || null, + linkedIssue || null, + linkedCorrection || null, + now, + now, + nextStatus === "deleted" ? now : null, + id + ); + if (nextStatus !== current.status) { + addStatusHistory(id, nextStatus, actor.id, statusNote, now); + } + if (adminReply && adminReply !== (current.admin_reply || "")) { + db.prepare( + "INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'admin_reply', ?, 1, ?)" + ).run(id, actor.id, adminReply, now); + } + if (workNote) { + db.prepare( + "INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)" + ).run(id, actor.id, workNote, now); + } + })(); + return getFeedbackForAdmin(id); +} + +function convertFeedbackToTodo(id, body, actor, todoPath = path.join(__dirname, "..", "..", "TODO.md")) { + const entry = getFeedbackForAdmin(id); + if (!entry) { + throw new Error("Feedback item was not found."); + } + const text = cleanText(body, 4000) || defaultTodoText(entry); + if (!text) { + throw new Error("TODO text is required."); + } + const current = fs.existsSync(todoPath) ? fs.readFileSync(todoPath, "utf8") : "# TODO\n\n## Done\n"; + const section = "## Feedback-Derived TODOs"; + const bullet = text + .split(/\r?\n/) + .map((line, index) => (index === 0 ? `- ${line}` : ` ${line}`)) + .join("\n"); + let next = current; + if (!current.includes(section)) { + next = current.replace(/\n## Done\b/, `\n${section}\n\n${bullet}\n\n## Done`); + if (next === current) { + next = `${current.replace(/\s*$/, "")}\n\n${section}\n\n${bullet}\n`; + } + } else { + next = current.replace(section, `${section}\n\n${bullet}`); + } + fs.writeFileSync(todoPath, next); + const now = Date.now(); + db.transaction(() => { + db.prepare( + "UPDATE feedback_entries SET linked_todo = ?, updated_at = ?, last_activity_at = ? WHERE id = ?" + ).run(text, now, now, id); + db.prepare( + "INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)" + ).run(id, actor.id, `Converted to TODO: ${text}`, now); + })(); + return text; +} + +function markFeedbackViewed(userId) { + if (!userId) return; + db.prepare( + "INSERT INTO feedback_view_state (user_id, last_seen_at) VALUES (?, ?) " + + "ON CONFLICT(user_id) DO UPDATE SET last_seen_at = excluded.last_seen_at" + ).run(userId, Date.now()); +} + +function notificationSummary(userId) { + if (!userId) { + return { solved: 0, needs_context: 0, not_worked: 0, total: 0 }; + } + const viewed = db + .prepare("SELECT last_seen_at FROM feedback_view_state WHERE user_id = ?") + .get(userId); + const since = viewed?.last_seen_at || 0; + const rows = db + .prepare( + "SELECT status, updated_at FROM feedback_entries WHERE submitter_id = ? AND updated_at > ? AND deleted_at IS NULL AND status != 'deleted'" + ) + .all(userId, since); + const summary = { solved: 0, needs_context: 0, not_worked: 0, total: 0 }; + for (const row of rows) { + if (SOLVED_STATUSES.has(row.status)) summary.solved += 1; + if (NEEDS_CONTEXT_STATUSES.has(row.status)) summary.needs_context += 1; + if (NOT_WORKING_STATUSES.has(row.status)) summary.not_worked += 1; + } + summary.total = summary.solved + summary.needs_context + summary.not_worked; + return summary; +} + +function feedbackOptions() { + return { + categories: FEEDBACK_CATEGORIES.map((value) => ({ value, label: CATEGORY_LABELS[value] })), + severities: FEEDBACK_SEVERITIES.map((value) => ({ value, label: SEVERITY_LABELS[value] })), + scopes: FEEDBACK_SCOPE_TYPES.map((value) => ({ value, label: SCOPE_LABELS[value] })), + statuses: FEEDBACK_STATUSES.map((value) => ({ + value, + label: STATUS_LABELS[value], + help: STATUS_HELP[value] + })) + }; +} + +function normalizeFeedbackInput(input = {}) { + const summary = cleanText(input.summary, 140); + const description = cleanText(input.description, 6000); + if (summary.length < 6) { + throw new Error("Feedback needs a short summary with at least 6 characters."); + } + if (description.length < 10) { + throw new Error("Feedback needs a description with at least 10 characters."); + } + const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "other"; + const severity = FEEDBACK_SEVERITIES.includes(input.severity) ? input.severity : "minor"; + const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : "page"; + return { + summary, + category, + severity, + scope_type: scopeType, + scope_label: cleanText(input.scope_label, 240), + target_metadata: sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata), + current_url: cleanUrl(input.current_url), + page_title: cleanText(input.page_title, 240), + description, + steps_to_reproduce: cleanText(input.steps_to_reproduce, 4000), + expected_behavior: cleanText(input.expected_behavior, 4000), + actual_behavior: cleanText(input.actual_behavior, 4000), + diagnostics: sanitizeJsonObject(input.diagnostics, sanitizeDiagnostics) + }; +} + +function enforceRateLimit(userId) { + const cutoff = Date.now() - RATE_LIMIT.windowMs; + const count = db + .prepare( + "SELECT COUNT(*) AS count FROM feedback_entries WHERE submitter_id = ? AND created_at >= ?" + ) + .get(userId, cutoff).count; + if (count >= RATE_LIMIT.max) { + throw new Error("Too many feedback reports were submitted recently. Please wait a few minutes."); + } +} + +function hydrateFeedback(row, { admin }) { + const parsed = { + ...decorateLabels(row), + target_metadata: parseJson(row.target_metadata_json, {}), + diagnostics: admin ? parseJson(row.diagnostics_json, {}) : {}, + comments: commentsFor(row.id, admin), + history: statusHistoryFor(row.id) + }; + if (!admin) { + delete parsed.submitter_id; + delete parsed.assigned_admin_id; + delete parsed.diagnostics_json; + parsed.comments = parsed.comments.filter((comment) => comment.visible_to_submitter); + } + return parsed; +} + +function commentsFor(feedbackId, admin) { + return db + .prepare( + "SELECT feedback_comments.*, user_profiles.internal_username AS actor_name " + + "FROM feedback_comments LEFT JOIN user_profiles ON user_profiles.id = feedback_comments.actor_id " + + "WHERE feedback_id = ? ORDER BY created_at ASC" + ) + .all(feedbackId) + .filter((row) => admin || row.visible_to_submitter) + .map((row) => ({ + ...row, + visible_to_submitter: Boolean(row.visible_to_submitter), + kind_label: commentKindLabel(row.kind) + })); +} + +function statusHistoryFor(feedbackId) { + return db + .prepare( + "SELECT feedback_status_history.*, user_profiles.internal_username AS actor_name " + + "FROM feedback_status_history LEFT JOIN user_profiles ON user_profiles.id = feedback_status_history.actor_id " + + "WHERE feedback_id = ? ORDER BY created_at ASC" + ) + .all(feedbackId) + .map((row) => decorateLabels(row)); +} + +function addStatusHistory(feedbackId, status, actorId, note, now) { + db.prepare( + "INSERT INTO feedback_status_history (feedback_id, status, actor_id, note, created_at) VALUES (?, ?, ?, ?, ?)" + ).run(feedbackId, status, actorId || null, cleanText(note, 1000), now); +} + +function touchFeedback(id, now = Date.now()) { + db.prepare("UPDATE feedback_entries SET updated_at = ?, last_activity_at = ? WHERE id = ?").run(now, now, id); +} + +function decorateLabels(row) { + return { + ...row, + category_label: CATEGORY_LABELS[row.category] || row.category, + severity_label: SEVERITY_LABELS[row.severity] || row.severity, + scope_label_display: row.scope_label || SCOPE_LABELS[row.scope_type] || row.scope_type, + scope_type_label: SCOPE_LABELS[row.scope_type] || row.scope_type, + status_label: STATUS_LABELS[row.status] || row.status, + status_help: STATUS_HELP[row.status] || "" + }; +} + +function defaultTodoText(entry) { + const url = entry.current_url ? ` (${entry.current_url})` : ""; + return `[Feedback ${entry.id.slice(0, 8)}] ${entry.summary}${url}: ${entry.description.slice(0, 300)}`; +} + +function sanitizeTargetMetadata(value) { + return { + selector: cleanText(value.selector, 500), + tag: cleanText(value.tag, 40), + text: cleanText(value.text, 300), + aria_label: cleanText(value.aria_label, 200), + title: cleanText(value.title, 200), + role: cleanText(value.role, 80), + label: cleanText(value.label, 200), + heading: cleanText(value.heading, 200), + page_url: cleanUrl(value.page_url), + page_title: cleanText(value.page_title, 240), + viewport: cleanText(value.viewport, 80) + }; +} + +function sanitizeDiagnostics(value) { + return { + user_agent: cleanText(value.user_agent, 500), + viewport: cleanText(value.viewport, 80), + language: cleanText(value.language, 80) + }; +} + +function sanitizeJsonObject(value, sanitizer) { + const object = typeof value === "object" && value && !Array.isArray(value) ? value : {}; + return removeEmptyFields(sanitizer(object)); +} + +function removeEmptyFields(value) { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== "" && entry !== null && entry !== undefined)); +} + +function parseJson(value, fallback) { + try { + const parsed = JSON.parse(value || ""); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback; + } catch { + return fallback; + } +} + +function cleanText(value, max) { + return String(value || "").replace(/\s+\n/g, "\n").trim().slice(0, max); +} + +function cleanUrl(value) { + const raw = cleanText(value, 1000); + if (!raw) return ""; + try { + const url = new URL(raw, "http://localhost"); + if (!["http:", "https:"].includes(url.protocol)) return ""; + return raw; + } catch { + return ""; + } +} + +function commentKindLabel(kind) { + return { + submitter_comment: "Submitter comment", + admin_reply: "Admin reply", + work_note: "Private work note" + }[kind] || kind; +} + +function severityOrderSql() { + return "CASE feedback_entries.severity " + + "WHEN 'security_sensitive' THEN 0 " + + "WHEN 'urgent' THEN 1 " + + "WHEN 'broken' THEN 2 " + + "WHEN 'performance_issue' THEN 3 " + + "WHEN 'confusing' THEN 4 " + + "WHEN 'minor' THEN 5 " + + "ELSE 6 END, feedback_entries.last_activity_at DESC"; +} + +module.exports = { + FEEDBACK_CATEGORIES, + FEEDBACK_SCOPE_TYPES, + FEEDBACK_SEVERITIES, + FEEDBACK_STATUSES, + USER_VISIBLE_STATUSES, + adminUpdateFeedback, + convertFeedbackToTodo, + createFeedback, + feedbackOptions, + getFeedbackForAdmin, + getFeedbackForSubmitter, + listFeedbackForAdmin, + listMyFeedback, + listPublicFeedback, + markFeedbackViewed, + notificationSummary, + addSubmitterComment +}; diff --git a/src/web/public/app.js b/src/web/public/app.js index 4a791ba..03fd27f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -945,4 +945,280 @@ markCopyResult(false); } }); + + const feedbackModal = document.querySelector("[data-feedback-modal]"); + const feedbackForm = document.querySelector("[data-feedback-form]"); + const contextMenu = document.querySelector("[data-lumi-context-menu]"); + let contextTarget = null; + let highlightedFeedbackTarget = null; + + const parseJson = (value, fallback = {}) => { + try { + return JSON.parse(value || ""); + } catch { + return fallback; + } + }; + + const feedbackOptions = parseJson(feedbackModal?.dataset.feedbackOptions, {}); + + const setSelectOptions = (select, entries, preferred) => { + if (!select || !Array.isArray(entries)) return; + select.replaceChildren(); + entries.forEach((entry) => { + const option = document.createElement("option"); + option.value = entry.value; + option.textContent = entry.label; + if (entry.value === preferred) option.selected = true; + select.append(option); + }); + }; + + if (feedbackForm) { + setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug"); + setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor"); + setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, "page"); + } + + const sensitiveSelector = [ + "input[type='password']", + "input[type='hidden']", + "input[name*='token' i]", + "input[name*='secret' i]", + "input[name*='password' i]", + "textarea[name*='token' i]", + "textarea[name*='secret' i]", + "textarea[name*='password' i]" + ].join(","); + + const safeText = (value, max = 300) => String(value || "").replace(/\s+/g, " ").trim().slice(0, max); + + const cssPath = (element) => { + if (!element || element === document.body || element === document.documentElement) return "body"; + if (element.id) return `#${selectorEscape(element.id)}`; + const parts = []; + let node = element; + while (node && node.nodeType === 1 && node !== document.body && parts.length < 5) { + let part = node.tagName.toLowerCase(); + if (node.classList.length) { + part += `.${Array.from(node.classList).slice(0, 2).map(selectorEscape).join(".")}`; + } + const parent = node.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter((child) => child.tagName === node.tagName); + if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})`; + } + parts.unshift(part); + node = parent; + } + return parts.join(" > ") || "body"; + }; + + const nearestText = (element, selector) => { + const match = element?.closest?.(selector); + return safeText(match?.innerText || match?.textContent || "", 200); + }; + + const safeElementText = (element) => { + if (!element || element.matches?.(sensitiveSelector)) return ""; + if (["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName)) { + return ""; + } + return safeText(element.innerText || element.textContent || "", 300); + }; + + const feedbackTargetMetadata = (element) => { + const target = element?.closest?.("button, a, input, textarea, select, label, summary, [role], .card, section, main") || element || document.body; + return { + selector: cssPath(target), + tag: target?.tagName?.toLowerCase?.() || "", + text: safeElementText(target), + aria_label: safeText(target?.getAttribute?.("aria-label"), 200), + title: safeText(target?.getAttribute?.("title"), 200), + role: safeText(target?.getAttribute?.("role"), 80), + label: nearestText(target, "label"), + heading: nearestText(target, "section, .card"), + page_url: window.location.href, + page_title: document.title, + viewport: `${window.innerWidth}x${window.innerHeight}` + }; + }; + + const currentPageLabel = () => { + const h1 = document.querySelector("h1"); + return safeText(h1?.innerText || document.title, 180); + }; + + const clearFeedbackHighlight = () => { + highlightedFeedbackTarget?.classList?.remove("feedback-target-highlight"); + highlightedFeedbackTarget = null; + }; + + const openFeedbackModal = ({ scope = "page", target = null } = {}) => { + if (!feedbackModal || !feedbackForm) return; + hideContextMenu(); + clearFeedbackHighlight(); + feedbackForm.reset(); + setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug"); + setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor"); + setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, scope); + const metadata = target ? feedbackTargetMetadata(target) : {}; + const targetInput = feedbackForm.querySelector("[data-feedback-target-metadata]"); + const urlInput = feedbackForm.querySelector("[data-feedback-current-url]"); + const titleInput = feedbackForm.querySelector("[data-feedback-page-title]"); + const diagnosticsInput = feedbackForm.querySelector("[data-feedback-diagnostics]"); + const scopeLabel = feedbackForm.querySelector("[data-feedback-scope-label]"); + const status = feedbackForm.querySelector("[data-feedback-status]"); + if (targetInput) targetInput.value = JSON.stringify(metadata); + if (urlInput) urlInput.value = window.location.href; + if (titleInput) titleInput.value = document.title; + if (diagnosticsInput) diagnosticsInput.value = "{}"; + if (scopeLabel) scopeLabel.value = scope === "element" + ? (metadata.label || metadata.text || metadata.selector || "Clicked element") + : currentPageLabel(); + if (status) status.textContent = ""; + if (target && scope === "element") { + highlightedFeedbackTarget = target; + highlightedFeedbackTarget.classList.add("feedback-target-highlight"); + } + feedbackModal.classList.add("is-open"); + feedbackModal.setAttribute("aria-hidden", "false"); + feedbackForm.querySelector("input[name='summary']")?.focus(); + }; + + const closeFeedbackModal = () => { + if (!feedbackModal) return; + feedbackModal.classList.remove("is-open"); + feedbackModal.setAttribute("aria-hidden", "true"); + clearFeedbackHighlight(); + }; + + document.addEventListener("click", (event) => { + const openButton = event.target.closest("[data-feedback-open]"); + if (openButton) { + event.preventDefault(); + openFeedbackModal({ scope: openButton.dataset.feedbackScope || "page" }); + return; + } + if (event.target.closest("[data-feedback-close]") || event.target === feedbackModal) { + event.preventDefault(); + closeFeedbackModal(); + } + }); + + feedbackForm?.addEventListener("submit", async (event) => { + event.preventDefault(); + const submitter = feedbackForm.querySelector("button[type='submit']"); + const status = feedbackForm.querySelector("[data-feedback-status]"); + const includeBrowser = feedbackForm.querySelector("[data-feedback-include-browser]")?.checked; + const formData = new FormData(feedbackForm); + const payload = Object.fromEntries(formData.entries()); + payload.target_metadata = parseJson(payload.target_metadata, {}); + payload.diagnostics = includeBrowser + ? { + user_agent: navigator.userAgent, + viewport: `${window.innerWidth}x${window.innerHeight}`, + language: navigator.language || "" + } + : {}; + submitter.disabled = true; + if (status) status.textContent = "Submitting feedback..."; + try { + const response = await fetch("/api/feedback", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(payload) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "Feedback could not be submitted."); + if (status) status.textContent = result.message || "Feedback submitted."; + window.setTimeout(() => { + closeFeedbackModal(); + window.location.href = `/feedback?id=${encodeURIComponent(result.id)}`; + }, 700); + } catch (error) { + if (status) status.textContent = error.message; + submitter.disabled = false; + } + }); + + const isEditableTarget = (target) => { + const element = target?.closest?.("input, textarea, [contenteditable='true']"); + return element && !element.matches("[readonly], [disabled]"); + }; + + const updateContextMenuItems = (target) => { + if (!contextMenu) return; + const editable = Boolean(isEditableTarget(target)); + contextMenu.querySelector("[data-context-action='cut']")?.toggleAttribute("hidden", !editable); + contextMenu.querySelector("[data-context-action='paste']")?.toggleAttribute("hidden", !editable); + contextMenu.querySelector("[data-context-action='copy']")?.toggleAttribute("hidden", false); + }; + + const showContextMenu = (event) => { + if (!contextMenu || event.shiftKey) return; + event.preventDefault(); + contextTarget = event.target; + updateContextMenuItems(contextTarget); + contextMenu.hidden = false; + const width = contextMenu.offsetWidth || 180; + const height = contextMenu.offsetHeight || 260; + contextMenu.style.left = `${Math.min(event.clientX, window.innerWidth - width - 8)}px`; + contextMenu.style.top = `${Math.min(event.clientY, window.innerHeight - height - 8)}px`; + contextMenu.querySelector("button:not([hidden])")?.focus(); + }; + + function hideContextMenu() { + if (!contextMenu) return; + contextMenu.hidden = true; + } + + const linkForTarget = (target) => { + const element = target?.closest?.("[id]") || document.querySelector("h1[id]") || null; + const url = new URL(window.location.href); + if (element?.id) url.hash = element.id; + return url.toString(); + }; + + const runContextAction = async (action) => { + const editable = isEditableTarget(contextTarget); + try { + if (action === "back") window.history.back(); + if (action === "forward") window.history.forward(); + if (action === "copy") { + const selection = window.getSelection?.().toString(); + await copyText(selection || linkForTarget(contextTarget)); + } + if (action === "cut" && editable) document.execCommand("cut"); + if (action === "paste" && editable) { + const text = await navigator.clipboard?.readText?.(); + if (text !== undefined) document.execCommand("insertText", false, text); + } + if (action === "link") await copyText(linkForTarget(contextTarget)); + if (action === "reload") window.location.reload(true); + if (action === "feedback") openFeedbackModal({ scope: "element", target: contextTarget }); + } catch { + if (action === "paste" && editable) document.execCommand("paste"); + } finally { + if (action !== "feedback") hideContextMenu(); + } + }; + + document.addEventListener("contextmenu", showContextMenu); + document.addEventListener("click", (event) => { + const button = event.target.closest("[data-context-action]"); + if (!button || !contextMenu?.contains(button)) { + hideContextMenu(); + return; + } + event.preventDefault(); + runContextAction(button.dataset.contextAction); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + hideContextMenu(); + closeFeedbackModal(); + } + }); + window.addEventListener("blur", hideContextMenu); })(); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 6a75d57..e952975 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1255,6 +1255,274 @@ body { color: var(--ink); } +.feedback-floating-button { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 80; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 11px 14px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--sea); + color: var(--ink-inverse, #081114); + font-weight: 800; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24); + cursor: pointer; +} + +.feedback-floating-badge { + min-width: 22px; + min-height: 22px; + padding: 2px 7px; + border-radius: 999px; + background: var(--rose); + color: #fff; + font-size: 12px; + line-height: 18px; + text-align: center; +} + +.feedback-modal { + max-width: 820px; +} + +.feedback-submit-status { + min-height: 20px; + color: var(--ink-soft); + font-weight: 700; +} + +.feedback-page-actions, +.feedback-badge-row, +.feedback-two-col, +.feedback-admin-filters, +.feedback-detail-grid { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.feedback-badge-row { + margin-top: 10px; +} + +.feedback-status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-2); + color: var(--ink); + font-weight: 700; +} + +.feedback-status-pill.solved { + border-color: rgba(48, 170, 112, 0.45); +} + +.feedback-status-pill.needs-context { + border-color: rgba(236, 91, 91, 0.55); +} + +.feedback-status-pill.not-worked { + border-color: rgba(148, 163, 184, 0.55); +} + +.feedback-list, +.feedback-admin-list, +.feedback-comments { + display: grid; + gap: 10px; +} + +.feedback-row, +.feedback-admin-item { + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-2); +} + +.feedback-row { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding: 14px; +} + +.feedback-row h3 { + margin: 0 0 4px; +} + +.feedback-row.is-selected { + outline: 2px solid var(--sea); +} + +.feedback-detail-grid { + align-items: stretch; + margin: 14px 0; +} + +.feedback-detail-grid > div { + flex: 1 1 170px; + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-2); +} + +.feedback-detail-grid span { + display: block; + color: var(--ink-soft); + font-size: 13px; +} + +.feedback-copy-block, +.feedback-comment { + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-2); +} + +.feedback-copy-block p, +.feedback-comment p { + white-space: pre-wrap; + margin-bottom: 0; +} + +.feedback-two-col > * { + flex: 1 1 280px; + min-width: 0; +} + +.feedback-comment-admin_reply { + border-left: 4px solid var(--sea); +} + +.feedback-comment-submit_comment, +.feedback-comment-submitter_comment { + border-left: 4px solid var(--sun); +} + +.feedback-comment-work_note { + border-left: 4px solid var(--ink-soft); + opacity: 0.92; +} + +.feedback-status-fixed, +.feedback-status-solved { + border-color: rgba(48, 170, 112, 0.45); + color: var(--success); +} + +.feedback-status-needs_more_context { + border-color: rgba(236, 91, 91, 0.55); + color: var(--rose); +} + +.feedback-status-duplicate, +.feedback-status-rejected, +.feedback-status-not_planned, +.feedback-status-wont_fix { + border-color: rgba(148, 163, 184, 0.55); + color: var(--ink-soft); +} + +.feedback-admin-item summary { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding: 14px; + cursor: pointer; +} + +.feedback-admin-item summary small { + display: block; + margin-top: 4px; + color: var(--ink-soft); +} + +.feedback-admin-body { + display: grid; + gap: 14px; + padding: 0 14px 14px; +} + +.feedback-metadata { + padding: 10px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-2); +} + +.feedback-metadata pre { + overflow: auto; + max-height: 260px; + margin: 8px 0 0; + padding: 10px; + border-radius: 10px; + background: var(--surface-3); + white-space: pre-wrap; +} + +.feedback-history { + display: grid; + gap: 8px; + margin: 10px 0 0; + padding-left: 20px; +} + +.feedback-target-highlight { + outline: 3px solid var(--sea) !important; + outline-offset: 3px; +} + +.lumi-context-menu { + position: fixed; + z-index: 120; + min-width: 180px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--card); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28); +} + +.lumi-context-menu button { + display: block; + width: 100%; + padding: 9px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ink); + text-align: left; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.lumi-context-menu button:hover, +.lumi-context-menu button:focus { + outline: none; + background: var(--surface-2); +} + +.checkbox-inline { + display: inline-flex; + gap: 8px; + align-items: center; +} + .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); diff --git a/src/web/server.js b/src/web/server.js index ab5a631..53d5cac 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -110,6 +110,19 @@ const { publishWebEvent, subscribeWebEvents } = require("../services/web-events"); +const { + addSubmitterComment, + adminUpdateFeedback, + convertFeedbackToTodo, + createFeedback, + feedbackOptions, + getFeedbackForSubmitter, + listFeedbackForAdmin, + listMyFeedback, + listPublicFeedback, + markFeedbackViewed, + notificationSummary +} = require("../services/feedback"); function ensureSessionSecret() { let secret = getSetting("session_secret"); @@ -2566,6 +2579,22 @@ function createWebServer({ loadPlugins, discordClient }) { res.status(400).json({ error: error.message }); } }); + app.post("/api/feedback", requireAuth, (req, res) => { + try { + const entry = createFeedback(req.body, req.session.user); + res.status(201).json({ + ok: true, + id: entry.id, + message: "Feedback submitted. You can review it from the Feedback page." + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } + }); + app.get("/api/feedback/notifications", requireAuth, (req, res) => { + res.set("Cache-Control", "no-store"); + res.json(notificationSummary(req.session.user.id)); + }); app.use((req, res, next) => { if (req.method !== "POST" || !isDestructivePath(req.path)) return next(); const confirmation = consumeConfirmation(req, normalizeAction(req.originalUrl)); @@ -2609,6 +2638,8 @@ function createWebServer({ loadPlugins, discordClient }) { res.locals.userInitial = req.session.user?.username ? req.session.user.username.charAt(0).toUpperCase() : ""; + res.locals.feedbackNotifications = notificationSummary(req.session.user?.id); + res.locals.feedbackOptions = feedbackOptions(); req.session.flash = null; trackModRole(db, req.session.user); res.locals.navSections = buildNavSections( @@ -3663,6 +3694,31 @@ function createWebServer({ loadPlugins, discordClient }) { res.redirect("/profile"); }); + app.get("/feedback", requireAuth, (req, res) => { + markFeedbackViewed(req.session.user.id); + res.locals.feedbackNotifications = notificationSummary(req.session.user.id); + const selected = req.query.id + ? getFeedbackForSubmitter(String(req.query.id), req.session.user.id) + : null; + res.render("feedback", { + title: "Feedback", + feedbackList: listPublicFeedback({ userId: req.session.user.id }), + myFeedback: listMyFeedback(req.session.user.id), + selectedFeedback: selected, + feedbackOptions: feedbackOptions() + }); + }); + + app.post("/feedback/:id/comment", requireAuth, (req, res) => { + try { + addSubmitterComment(req.params.id, req.body.comment, req.session.user); + setFlash(req, "success", "Comment added."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(`/feedback?id=${encodeURIComponent(req.params.id)}`); + }); + app.get("/health", (req, res) => { res.set("Cache-Control", "no-store"); res.json({ ok: true, ts: Date.now() }); @@ -4922,6 +4978,43 @@ function createWebServer({ loadPlugins, discordClient }) { res.send(lines.join("\n")); }); + app.get("/admin/feedback", requireRole("admin"), (req, res) => { + res.render("admin-feedback", { + title: "Feedback review", + feedbackItems: listFeedbackForAdmin(req.query), + filters: { + status: req.query.status || "", + category: req.query.category || "", + severity: req.query.severity || "", + scope: req.query.scope || "", + submitter: req.query.submitter || "", + needs_action: req.query.needs_action || "", + sort: req.query.sort || "last_activity" + }, + feedbackOptions: feedbackOptions() + }); + }); + + app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => { + try { + adminUpdateFeedback(req.params.id, req.body, req.session.user); + setFlash(req, "success", "Feedback updated."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(req.get("referer") || "/admin/feedback"); + }); + + app.post("/admin/feedback/:id/todo", requireRole("admin"), (req, res) => { + try { + convertFeedbackToTodo(req.params.id, req.body.todo_text, req.session.user); + setFlash(req, "success", "Feedback converted to TODO."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(req.get("referer") || "/admin/feedback"); + }); + app.get("/admin/privileges", requireRole("admin"), async (req, res) => { const discord = await buildDiscordPrivileges(discordClient); const twitch = await buildTwitchPrivileges(); @@ -5829,6 +5922,13 @@ function collectNavItems(user, pluginNav, currentPath) { authRequired: true, section: "community" }, + { + label: "Feedback", + path: "/feedback", + role: "public", + authRequired: true, + section: "community" + }, { label: "Mods List", path: "/moderator", role: "mod", section: "moderation" }, { label: "Admin", path: "/admin", role: "admin", section: "admin" }, { @@ -5855,6 +5955,7 @@ function collectNavItems(user, pluginNav, currentPath) { role: "admin", section: "admin" }, + { label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" }, { label: "Logs", path: "/admin/logs", role: "admin", section: "admin" }, { label: "Updates", path: "/admin/updates", role: "admin", section: "admin" }, { diff --git a/src/web/views/admin-feedback.ejs b/src/web/views/admin-feedback.ejs new file mode 100644 index 0000000..8d136e2 --- /dev/null +++ b/src/web/views/admin-feedback.ejs @@ -0,0 +1,212 @@ +<%- include("partials/layout-top", { title }) %> +
+ <%- include("partials/page-header", { + eyebrow: "Administration", + pageTitle: "Feedback review", + description: "Review core feedback, reply to submitters, record private work notes, and convert useful reports into TODO entries." + }) %> +
+ + + + + + + + + Reset +
+
+ +
+
+
+

Feedback queue

+

<%= feedbackItems.length %> item(s) shown.

+
+
+ <% if (!feedbackItems.length) { %> +
No feedback matches this filter.
+ <% } else { %> +
+ <% feedbackItems.forEach((item) => { %> + + <% }) %> +
+ <% } %> +
+<%- include("partials/layout-bottom") %> diff --git a/src/web/views/feedback.ejs b/src/web/views/feedback.ejs new file mode 100644 index 0000000..b2b1987 --- /dev/null +++ b/src/web/views/feedback.ejs @@ -0,0 +1,136 @@ +<%- include("partials/layout-top", { title }) %> +
+ <%- include("partials/page-header", { + eyebrow: "Community", + pageTitle: "Feedback", + description: "Submit one clear report per issue and track your own feedback." + }) %> +
+ +

Small, specific reports are easier to review than broad combined lists.

+
+
+ +
+

Your feedback notifications

+
+ + + +
+

Opening this page marks current feedback updates as viewed.

+
+ +
+

My feedback

+ <% if (!myFeedback.length) { %> +
You have not submitted feedback yet.
+ <% } else { %> +
+ <% myFeedback.forEach((item) => { %> + + <% }) %> +
+ <% } %> +
+ +<% if (selectedFeedback) { %> +
+
+
+

<%= selectedFeedback.summary %>

+

<%= selectedFeedback.status_label %>: <%= selectedFeedback.status_help %>

+
+ +
+
+
Category<%= selectedFeedback.category_label %>
+
Severity<%= selectedFeedback.severity_label %>
+
Scope<%= selectedFeedback.scope_label_display %>
+
Submitted<%= new Date(selectedFeedback.created_at).toLocaleString() %>
+
+
+ Description +

<%= selectedFeedback.description %>

+
+ <% if (selectedFeedback.steps_to_reproduce) { %> +
Steps to reproduce

<%= selectedFeedback.steps_to_reproduce %>

+ <% } %> + <% if (selectedFeedback.expected_behavior || selectedFeedback.actual_behavior) { %> +
+ + +
+ <% } %> + <% if (selectedFeedback.admin_reply) { %> +
Admin reply

<%= selectedFeedback.admin_reply %>

+ <% } %> +

Conversation

+ <% if (!selectedFeedback.comments.length) { %> +

No comments yet.

+ <% } else { %> +
+ <% selectedFeedback.comments.forEach((comment) => { %> + + <% }) %> +
+ <% } %> +
+
+ + + Use this when an admin asks for more detail or you have extra context. +
+
+ +
+
+
+<% } %> + +
+

Community feedback overview

+

This list hides submitter identity and detailed diagnostics.

+ <% if (!feedbackList.length) { %> +
No feedback has been submitted yet.
+ <% } else { %> +
+ + + + + + + + + + + + <% feedbackList.forEach((item) => { %> + + + + + + + + <% }) %> + +
SummaryCategoryScopeStatusUpdated
+ <%= item.summary %> + <% if (item.is_mine) { %>Mine<% } %> + <%= item.category_label %><%= item.scope_label_display %><%= new Date(item.updated_at).toLocaleString() %>
+
+ <% } %> +
+<%- include("partials/layout-bottom") %> diff --git a/src/web/views/partials/layout-bottom.ejs b/src/web/views/partials/layout-bottom.ejs index bfca27e..66210b8 100644 --- a/src/web/views/partials/layout-bottom.ejs +++ b/src/web/views/partials/layout-bottom.ejs @@ -11,6 +11,94 @@ + <% if (user) { %> + <% const feedbackNotice = typeof feedbackNotifications !== "undefined" ? feedbackNotifications : { total: 0, solved: 0, needs_context: 0, not_worked: 0 }; %> + <% const feedbackOptionData = typeof feedbackOptions !== "undefined" ? feedbackOptions : {}; %> + + + + <% } %>