diff --git a/TODO.md b/TODO.md index 6fbb1aa..f51e25b 100644 --- a/TODO.md +++ b/TODO.md @@ -122,8 +122,429 @@ 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 + +Current state on `experimental-feedback-system` as of 2026-06-18: the core feedback system is implemented locally and not yet pushed after the latest context-menu, review action, and screenshot attachment refinements. The detailed checklist below is retained for hardening, but this current-state block is the source of truth. + +### Implemented Locally + +- Core-level feedback storage in SQLite: + - `feedback_entries` + - `feedback_comments` + - `feedback_status_history` + - `feedback_view_state` +- Logged-in feedback submission through: + - persistent floating feedback button + - site-wide custom context menu + - `/api/feedback` +- `/feedback` under Community navigation for logged-in users. +- `/admin/feedback` under Admin navigation for admin review. +- Generic issue categories, severity values, scope types, and status labels. +- Feedback modal fields for summary, category, severity, scope, description, steps, expected behavior, actual behavior, page URL, page title, clicked-element metadata, and optional browser/viewport diagnostics. +- Element-targeted feedback from right-click context menu with safe metadata: + - selector/path + - tag + - visible text snippet + - aria label/title/role + - nearest label/heading + - page URL/title + - viewport size +- Sensitive form field values are not captured by the element metadata collector. +- Selected feedback target is highlighted while the modal is open and cleared when closed. +- User feedback center: + - public non-identifying feedback summary list + - “My feedback” section + - own-feedback detail view + - submitter comments/follow-up + - admin replies visible to submitter +- Per-user notification counters for solved, needs-context, and not-worked/not-planned outcomes. +- Notification counters reset/update when the user opens `/feedback`. +- Admin review page: + - full feedback details + - submitter identity + - target metadata + - optional diagnostics + - status history + - comments/replies + - private work notes + - filters for status, category, severity, scope, submitter, and needs-action + - filters for plugin/area text and submitted date range + - sorting by newest, oldest, severity, status, and last activity + - status/category/severity updates + - admin reply to submitter + - private admin work note + - request-more-context support through `needs_more_context` + - duplicate/rejected/not-planned/won't-fix/archive/delete statuses + - manual links to issue/correction references +- Admin review actions: + - `Finalize & Close` + - reopen closed feedback + - hard-delete feedback, comments, private notes, status history, and screenshots + - sensitive-data cleanup for screenshots, diagnostics, target metadata, and public admin replies + - feedback rows are collapsed by default +- Moderate submission rate limit. +- Validation against empty/too-short feedback. +- Server-side permission checks for submission, own-feedback details, submitter comments, and admin review. +- EJS escaping and sanitized server-side JSON/text handling for submitted feedback. +- AI Improvement Center remains separate; `wrong_tool_usage` classification already exists there. +- Custom context menu: + - Back + - Forward + - Copy + - Cut + - Paste + - Link to here + - Hard reload + - Feedback +- Context menu availability behavior: + - all actions remain visible + - unavailable actions are greyed out and non-interactive + - Back/Forward availability is history-aware where browser APIs allow + - Copy is available only for selected text or right-clicked images + - Cut is available only for selected text inside editable fields + - Paste is available only when an editable field is active and readable clipboard text exists + - Link to here, Hard reload, and Feedback remain available +- Context menu tooltips: + - show after one second of hover + - explain each action + - use fixed positioning + - clamp to the viewport so they do not overflow or expand the page +- Context menu keyboard behavior: + - arrow-key navigation + - Home/End navigation + - Enter/Space activation + - Tab focus wrapping while open +- Scope label/target field: + - displayed as a generated read-only “Detected target” field + - updates when “Feedback is about” changes + - is regenerated server-side and does not trust submitted free text +- Optional screenshot support: + - explicit opt-in only + - supports image upload + - supports pasted screenshots from the clipboard where browsers allow + - avoids browser tab/window picker capture so the browser prompt is not submitted + - shows an attached screenshot preview + - lets users remove the screenshot before submitting + - stores screenshots under `data/feedback/screenshots` + - exposes screenshots only to the submitter or admins through authenticated routes +- General protected attachments: + - supports up to three optional PNG/JPEG/WebP/PDF/text files per feedback item + - stores attachments under `data/feedback/attachments` + - exposes attachments only to the submitter or admins through authenticated routes + - supports admin cleanup/removal +- Public discussion on existing feedback: + - logged-in users can open public feedback detail without private diagnostics or attachments + - logged-in users can mark existing feedback as affecting them too + - logged-in users can add public context comments to existing feedback + +### Partially Implemented + +- Optional diagnostics exist for browser/user-agent, viewport, language, current URL, page title, selected scope, target metadata, screenshot attachment source, and opt-in sanitized page structure snapshots. +- Admin filters cover status/category/severity/scope/plugin-area/submitter/date/needs-action. Richer custom-scope filters are not implemented. +- OKF correction conversion is deferred for a later pass. Task tracking remains manual and outside the feedback feature. +- Duplicate handling includes lightweight similar-feedback suggestions from summary, description, scope, category, and page path; “also affected” counts; and public context comments, but not full duplicate merging. +- Feedback data is in SQLite under `data/app.db`, which is preserved by the update system through the existing protected `data/` path. Screenshot files are stored under `data/feedback/screenshots`, also covered by the protected `data/` path. + +### Remaining Work + +- Full scroll-page or automatic current-tab screenshot capture is not implemented. Current browser APIs require prompts for page capture, so the implemented path is explicit upload/paste. +- Full duplicate merge workflow and richer duplicate detection beyond lightweight summary/description/scope/category/page matching. +- Richer custom scope targeting filters. +- OKF correction conversion for documentation/terminology/knowledge feedback, deferred. +- Broader browser testing for image clipboard copy and clipboard-read availability because browser support and permissions vary. + +- 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 screenshot attachment support without browser tab/window picker prompts. +- Screenshots must be opt-in by the submitting user. +- Do not require screenshots for feedback submission. +- Support uploaded screenshot image files. +- Support pasted screenshots from the clipboard where browsers allow it. +- Consider full-page screenshots only if a safe DOM-rendering implementation or browser extension path is added later. +- 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 OKF correction where relevant + - link to existing issue/correction + +### 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 public comment or marking that it affects the user too 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 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 screenshots by upload or clipboard paste. +- `/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, finalize and close, reopen closed feedback, and permanently delete feedback. +- 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: Removed feedback conversion actions from the experimental feedback system, added Finalize & Close/Reopen/Delete admin actions, made delete hard-remove feedback records/comments/notes/history/screenshots, kept admin feedback rows collapsed by default, and replaced browser tab capture screenshots with upload/clipboard-paste attachments. +- 2026-06-18: Fixed experimental feedback review buttons to use the stable save endpoint for Finalize & Close/Reopen and added a reusable Lumi `button-group` utility, including the `.field.button-group` specificity case, so review buttons align horizontally. +- 2026-06-18: Moved feedback screenshot guidance above Description, centered feedback screenshot/form action rows, and moved feedback modal sizing overrides into the later-loaded Lumi component layer so generic `.modal` styles no longer win. +- 2026-06-18: Added feedback “also affects me” records, user-facing similar-feedback suggestions before submit, admin plugin/area/date filters, sensitive-data cleanup controls, and keyboard navigation for the custom context menu; dropped external issue creation from the feedback roadmap and left OKF conversion deferred. +- 2026-06-18: Added protected general feedback attachments, protected attachment downloads, admin attachment cleanup/removal, viewer-safe public feedback details, and public context comments on existing feedback. +- 2026-06-18: Added opt-in sanitized page structure diagnostics for feedback, excluding sensitive form values and capping stored snapshot text server-side. +- 2026-06-18: Updated the experimental feedback context menu locally so all actions remain visible with disabled unavailable states, Back/Forward/Copy/Cut/Paste availability is context-aware, image/text copy and input-only cut/paste rules are enforced where browsers allow, and one-second viewport-clamped tooltips explain each action. +- 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, 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..756f39c --- /dev/null +++ b/scripts/verify-feedback-system.js @@ -0,0 +1,250 @@ +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); + db.prepare( + "INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)" + ).run("user-2", "SecondUser", 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", + screenshot_mode: "target", + hidden: "must not persist" + } + }, { id: "user-1" }, { + screenshot: { + path: "feedback/screenshots/test-shot.png", + mime: "image/png", + size: 2048 + } + }); + + assert.equal(entry.status, "new"); + assert.equal(entry.scope_label, "Clicked element: Save settings"); + assert.equal(entry.target_metadata.secret, undefined); + assert.equal(entry.diagnostics.hidden, undefined); + assert.equal(entry.screenshot.mime, "image/png"); + assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true); + assert.equal(feedback.listMyFeedback("user-1").length, 1); + assert.equal(feedback.supportFeedback(entry.id, { id: "user-2" }), 1); + assert.equal(feedback.listPublicFeedback({ userId: "user-2" })[0].supported_by_me, true); + + feedback.addSubmitterComment(entry.id, "This also happens after a hard reload.", { id: "user-1" }); + feedback.addSubmitterComment(entry.id, "I see this too on the same settings page.", { id: "user-2" }); + const withComment = feedback.getFeedbackForSubmitter(entry.id, "user-1"); + assert.equal(withComment.comments.length, 2); + assert.equal(withComment.comments[0].kind, "submitter_comment"); + assert.equal(withComment.comments[1].kind, "public_comment"); + const publicViewerEntry = feedback.getFeedbackForViewer(entry.id, "user-2"); + assert.equal(publicViewerEntry.is_mine, false); + assert.equal(publicViewerEntry.screenshot, null); + assert(publicViewerEntry.comments.some((comment) => comment.kind === "public_comment")); + + feedback.markFeedbackViewed("user-1"); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2); + 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); + + feedback.adminUpdateFeedback(entry.id, { + status: "closed", + category: "bug", + severity: "urgent", + status_note: "Finalized after verification." + }, { id: "admin-1" }); + assert.equal(feedback.getFeedbackForAdmin(entry.id).status, "closed"); + feedback.adminUpdateFeedback(entry.id, { + status: "reviewed", + category: "bug", + severity: "urgent", + status_note: "Reopened after verification." + }, { id: "admin-1" }); + assert.equal(feedback.getFeedbackForAdmin(entry.id).status, "reviewed"); + assert(feedback.findSimilarFeedback({ + summary: "Settings save button does nothing", + category: "bug", + scope_type: "element", + current_url: "http://localhost/admin/settings" + }, { userId: "user-2" }).some((match) => match.id === entry.id)); + assert(feedback.findSimilarFeedback({ + summary: "Controls are weird", + description: "Clicking the save button does not apply changes after editing settings.", + category: "bug", + scope_type: "element", + current_url: "http://localhost/admin/settings" + }, { userId: "user-2" }).some((match) => match.id === entry.id)); + + const cleanupEntry = feedback.createFeedback({ + summary: "Sensitive screenshot cleanup", + category: "bug", + severity: "minor", + scope_type: "page", + description: "This report exists to verify cleanup behavior.", + current_url: "http://localhost/admin/feedback", + page_title: "Feedback review", + diagnostics: { user_agent: "sensitive-agent" } + }, { id: "user-1" }, { + screenshot: { + path: "feedback/screenshots/cleanup-shot.png", + mime: "image/png", + size: 1024 + }, + attachments: [{ + path: "feedback/attachments/cleanup-note.txt", + mime: "text/plain", + size: 128, + original_name: "cleanup-note.txt" + }] + }); + assert.equal(feedback.getFeedbackForAdmin(cleanupEntry.id).attachments.length, 1); + let cleanedScreenshot = ""; + let cleanedAttachment = ""; + feedback.cleanupFeedback(cleanupEntry.id, { + clear_screenshot: "1", + clear_attachments: "1", + clear_diagnostics: "1", + clear_target_metadata: "1" + }, { id: "admin-1" }, { + deleteScreenshot(relativePath) { + cleanedScreenshot = relativePath; + }, + deleteAttachment(relativePath) { + cleanedAttachment = relativePath; + } + }); + const cleaned = feedback.getFeedbackForAdmin(cleanupEntry.id); + assert.equal(cleaned.screenshot, null); + assert.deepEqual(cleaned.diagnostics, {}); + assert.deepEqual(cleaned.target_metadata, {}); + assert.equal(cleaned.attachments.length, 0); + assert.equal(cleanedScreenshot, "feedback/screenshots/cleanup-shot.png"); + assert.equal(cleanedAttachment, "feedback/attachments/cleanup-note.txt"); + + 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")); + assert(userRendered.includes("Open screenshot")); + assert(userRendered.includes("Community comment")); + const supporterRendered = ejs.render(fs.readFileSync(feedbackView, "utf8"), { + ...commonLocals, + user: { id: "user-2", username: "SecondUser" }, + title: "Feedback", + feedbackList: feedback.listPublicFeedback({ userId: "user-2" }), + myFeedback: feedback.listMyFeedback("user-2"), + selectedFeedback: null + }, { filename: feedbackView }); + assert(supporterRendered.includes("Marked as affecting you too.")); + + 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: "", area: "", submitter: "", date_from: "", date_to: "", needs_action: "", sort: "last_activity" } + }, { filename: adminView }); + assert(adminRendered.includes("Feedback queue")); + assert(!adminRendered.includes("Convert useful reports")); + assert(adminRendered.includes("Finalize & Close")); + assert(adminRendered.includes("Delete")); + assert(adminRendered.includes("Open attached screenshot")); + assert(adminRendered.includes("Sensitive data cleanup")); + assert(adminRendered.includes("Remove attachments")); + assert(adminRendered.includes("Plugin/area")); + assert(adminRendered.includes("1 also affected")); + + let deletedScreenshot = ""; + feedback.deleteFeedback(entry.id, { + deleteScreenshot(relativePath) { + deletedScreenshot = relativePath; + } + }); + assert.equal(feedback.getFeedbackForAdmin(entry.id), null); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM feedback_comments WHERE feedback_id = ?").get(entry.id).count, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS count FROM feedback_status_history WHERE feedback_id = ?").get(entry.id).count, 0); + assert.equal(deletedScreenshot, "feedback/screenshots/test-shot.png"); + + 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..dc8b0b1 100644 --- a/src/services/db.js +++ b/src/services/db.js @@ -149,6 +149,91 @@ 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 '{}', + screenshot_path TEXT, + screenshot_mime TEXT, + screenshot_size INTEGER, + 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 + ); + + CREATE TABLE IF NOT EXISTS feedback_support ( + feedback_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (feedback_id, user_id) + ); + + CREATE INDEX IF NOT EXISTS feedback_support_feedback_idx ON feedback_support (feedback_id, created_at); + + CREATE TABLE IF NOT EXISTS feedback_attachments ( + id TEXT PRIMARY KEY, + feedback_id TEXT NOT NULL, + storage_path TEXT NOT NULL, + original_name TEXT, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + kind TEXT NOT NULL DEFAULT 'attachment', + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS feedback_attachments_feedback_idx ON feedback_attachments (feedback_id, created_at); `); const columns = db @@ -211,6 +296,20 @@ function migrate() { db.exec("ALTER TABLE user_profiles ADD COLUMN username_updated_at INTEGER"); } + const feedbackColumns = db + .prepare("PRAGMA table_info(feedback_entries)") + .all() + .map((column) => column.name); + if (!feedbackColumns.includes("screenshot_path")) { + db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_path TEXT"); + } + if (!feedbackColumns.includes("screenshot_mime")) { + db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_mime TEXT"); + } + if (!feedbackColumns.includes("screenshot_size")) { + db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_size INTEGER"); + } + migrateLegacyUsers(); } diff --git a/src/services/feedback.js b/src/services/feedback.js new file mode 100644 index 0000000..e2cd465 --- /dev/null +++ b/src/services/feedback.js @@ -0,0 +1,952 @@ +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", + "closed", + "archived", + "deleted" +]); + +const USER_VISIBLE_STATUSES = new Set(FEEDBACK_STATUSES.filter((status) => status !== "deleted")); +const SOLVED_STATUSES = new Set(["fixed", "solved", "closed"]); +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", + closed: "Closed", + 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.", + closed: "Finalized and closed by an administrator.", + archived: "Archived for record keeping.", + deleted: "Deleted by an administrator." +}); + +function createFeedback(input, actor, options = {}) { + if (!actor?.id) { + throw new Error("Feedback requires a logged-in user."); + } + enforceRateLimit(actor.id); + const now = Date.now(); + const entry = normalizeFeedbackInput(input); + const screenshot = normalizeScreenshot(options.screenshot); + const attachments = normalizeAttachments(options.attachments); + 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, screenshot_path, screenshot_mime, screenshot_size, 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), + screenshot.path, + screenshot.mime, + screenshot.size, + now, + now, + now + ); + for (const attachment of attachments) { + db.prepare( + "INSERT INTO feedback_attachments (id, feedback_id, storage_path, original_name, mime, size, kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ).run( + crypto.randomUUID(), + id, + attachment.path, + attachment.original_name, + attachment.mime, + attachment.size, + attachment.kind, + 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); + const support = supportSummary(rows.map((row) => row.id), userId); + return rows.map((row) => ({ + ...decorateLabels(row), + support_count: support.counts.get(row.id) || 0, + supported_by_me: support.mine.has(row.id), + is_mine: userId ? row.submitter_id === userId : false, + submitter_id: undefined + })); +} + +function findSimilarFeedback(input = {}, options = {}) { + const summary = cleanText(input.summary, 140); + const description = cleanText(input.description, 6000); + const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : ""; + const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : ""; + const currentUrl = cleanUrl(input.current_url); + const pagePath = pagePathKey(currentUrl); + if (summary.length < 6 && description.length < 12 && !pagePath) return []; + const rows = db + .prepare( + "SELECT id, submitter_id, summary, description, category, severity, scope_type, scope_label, current_url, page_title, status, created_at, updated_at, last_activity_at " + + "FROM feedback_entries WHERE deleted_at IS NULL AND status NOT IN ('deleted', 'closed', 'solved', 'fixed', 'archived') " + + "ORDER BY last_activity_at DESC LIMIT 150" + ) + .all(); + const queryTokens = tokenSet(`${summary} ${description}`); + const matches = rows + .map((row) => { + const rowTokens = tokenSet(`${row.summary} ${row.description || ""} ${row.scope_label || ""} ${row.page_title || ""}`); + const samePath = pagePath && pagePath === pagePathKey(row.current_url); + const score = + jaccardScore(queryTokens, rowTokens) + + (scopeType && row.scope_type === scopeType ? 0.25 : 0) + + (category && row.category === category ? 0.15 : 0) + + (samePath ? 0.35 : 0); + return { row, score }; + }) + .filter(({ score }) => score >= 0.32) + .sort((a, b) => b.score - a.score) + .slice(0, Math.min(Number(options.limit || 5), 10)) + .map(({ row, score }) => ({ + ...decorateLabels(row), + match_score: Math.round(score * 100) / 100, + submitter_id: undefined + })); + const support = supportSummary(matches.map((row) => row.id), options.userId); + return matches.map((row) => ({ + ...row, + support_count: support.counts.get(row.id) || 0, + supported_by_me: support.mine.has(row.id) + })); +} + +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 getFeedbackForViewer(id, userId) { + const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id); + if (!row || row.deleted_at || row.status === "deleted") { + return null; + } + const entry = hydrateFeedback(row, { admin: false }); + entry.is_mine = Boolean(userId && row.submitter_id === userId); + const support = supportSummary([row.id], userId); + entry.support_count = support.counts.get(row.id) || 0; + entry.supported_by_me = support.mine.has(row.id); + if (!entry.is_mine) { + entry.screenshot = null; + entry.attachments = []; + } + return entry; +} + +function getFeedbackForAdmin(id) { + const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id); + if (!row) return null; + const entry = hydrateFeedback(row, { admin: true }); + const support = supportSummary([row.id]); + entry.support_count = support.counts.get(row.id) || 0; + return entry; +} + +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.area) { + where.push( + "(lower(feedback_entries.scope_label) LIKE lower(?) OR lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.page_title) LIKE lower(?))" + ); + params.push(`%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`); + } + const from = parseDateBoundary(filters.date_from, "start"); + if (from) { + where.push("feedback_entries.created_at >= ?"); + params.push(from); + } + const to = parseDateBoundary(filters.date_to, "end"); + if (to) { + where.push("feedback_entries.created_at <= ?"); + params.push(to); + } + 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"]; + const rows = 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); + const support = supportSummary(rows.map((row) => row.id)); + return rows.map((row) => { + const entry = hydrateFeedback(row, { admin: true }); + entry.support_count = support.counts.get(row.id) || 0; + return entry; + }); +} + +function addSubmitterComment(id, body, actor) { + const row = db.prepare("SELECT id, submitter_id, status, deleted_at FROM feedback_entries WHERE id = ?").get(id); + if (!row || row.deleted_at || row.status === "deleted" || !actor?.id) { + 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 kind = row.submitter_id === actor.id ? "submitter_comment" : "public_comment"; + const now = Date.now(); + db.transaction(() => { + db.prepare( + "INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, ?, ?, 1, ?)" + ).run(id, actor.id, kind, comment, now); + touchFeedback(id, now); + })(); +} + +function supportFeedback(id, actor) { + if (!actor?.id) { + throw new Error("Support requires a logged-in user."); + } + const row = db.prepare("SELECT id, status, deleted_at FROM feedback_entries WHERE id = ?").get(id); + if (!row || row.deleted_at || row.status === "deleted") { + throw new Error("Feedback item was not found."); + } + db.prepare( + "INSERT OR IGNORE INTO feedback_support (feedback_id, user_id, created_at) VALUES (?, ?, ?)" + ).run(id, actor.id, Date.now()); + return supportSummary([id], actor.id).counts.get(id) || 0; +} + +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 deleteFeedback(id, options = {}) { + const current = db.prepare("SELECT id, screenshot_path FROM feedback_entries WHERE id = ?").get(id); + if (!current) { + throw new Error("Feedback item was not found."); + } + const attachments = attachmentsFor(id); + db.transaction(() => { + db.prepare("DELETE FROM feedback_comments WHERE feedback_id = ?").run(id); + db.prepare("DELETE FROM feedback_status_history WHERE feedback_id = ?").run(id); + db.prepare("DELETE FROM feedback_support WHERE feedback_id = ?").run(id); + db.prepare("DELETE FROM feedback_attachments WHERE feedback_id = ?").run(id); + db.prepare("DELETE FROM feedback_entries WHERE id = ?").run(id); + })(); + if (current.screenshot_path && typeof options.deleteScreenshot === "function") { + options.deleteScreenshot(current.screenshot_path); + } + if (typeof options.deleteAttachment === "function") { + attachments.forEach((attachment) => options.deleteAttachment(attachment.storage_path)); + } + return current; +} + +function cleanupFeedback(id, input = {}, actor, options = {}) { + const current = getFeedbackForAdmin(id); + if (!current) { + throw new Error("Feedback item was not found."); + } + const changes = []; + const sets = []; + if (input.clear_screenshot === "1" && current.screenshot?.path) { + sets.push("screenshot_path = NULL", "screenshot_mime = NULL", "screenshot_size = NULL"); + changes.push("screenshot"); + } + if (input.clear_diagnostics === "1") { + sets.push("diagnostics_json = '{}'"); + changes.push("diagnostics"); + } + if (input.clear_target_metadata === "1") { + sets.push("target_metadata_json = '{}'"); + changes.push("target metadata"); + } + if (input.clear_admin_reply === "1") { + sets.push("admin_reply = NULL"); + changes.push("admin reply"); + } + const clearAttachments = input.clear_attachments === "1"; + const attachments = clearAttachments ? attachmentsFor(id) : []; + if (clearAttachments && attachments.length) { + changes.push("attachments"); + } + if (!sets.length) { + if (!clearAttachments || !attachments.length) { + throw new Error("Choose at least one feedback data field to clean."); + } + } + if (!sets.length && clearAttachments && attachments.length) { + sets.push("updated_at = updated_at"); + } + if (!changes.length) { + throw new Error("Choose at least one feedback data field to clean."); + } + const now = Date.now(); + db.transaction(() => { + db.prepare( + `UPDATE feedback_entries SET ${sets.join(", ")}, updated_at = ?, last_activity_at = ? WHERE id = ?` + ).run(now, now, id); + if (clearAttachments) { + db.prepare("DELETE FROM feedback_attachments WHERE feedback_id = ?").run(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 || null, `Cleaned sensitive feedback data: ${changes.join(", ")}.`, now); + })(); + if (input.clear_screenshot === "1" && current.screenshot?.path && typeof options.deleteScreenshot === "function") { + options.deleteScreenshot(current.screenshot.path); + } + if (clearAttachments && typeof options.deleteAttachment === "function") { + attachments.forEach((attachment) => options.deleteAttachment(attachment.storage_path)); + } + return getFeedbackForAdmin(id); +} + +function getFeedbackAttachment(feedbackId, attachmentId, userId, isAdmin = false) { + const row = db.prepare("SELECT id, submitter_id, deleted_at, status FROM feedback_entries WHERE id = ?").get(feedbackId); + if (!row || row.deleted_at || row.status === "deleted") { + return null; + } + if (!isAdmin && row.submitter_id !== userId) { + return null; + } + return db.prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? AND id = ?").get(feedbackId, attachmentId) || null; +} + +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"; + const targetMetadata = sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata); + const pageTitle = cleanText(input.page_title, 240); + return { + summary, + category, + severity, + scope_type: scopeType, + scope_label: deriveScopeLabel(scopeType, targetMetadata, pageTitle, input.current_url), + target_metadata: targetMetadata, + current_url: cleanUrl(input.current_url), + page_title: pageTitle, + 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 deriveScopeLabel(scopeType, metadata, pageTitle, currentUrl) { + const pageLabel = pageTitle || pagePathLabel(currentUrl) || "this page"; + const elementLabel = metadata.label || metadata.text || metadata.aria_label || metadata.title || metadata.selector; + if (scopeType === "element") { + return cleanText(elementLabel ? `Clicked element: ${elementLabel}` : `Clicked element on ${pageLabel}`, 240); + } + if (scopeType === "feature") return cleanText(`Feature/page: ${pageLabel}`, 240); + if (scopeType === "plugin") { + const plugin = pluginLabelFromUrl(currentUrl); + return cleanText(plugin ? `Plugin: ${plugin}` : `Plugin-related feedback on ${pageLabel}`, 240); + } + if (scopeType === "system") return cleanText(`System area: ${pageLabel}`, 240); + if (scopeType === "other") return cleanText(`Other feedback on ${pageLabel}`, 240); + return cleanText(`Whole page: ${pageLabel}`, 240); +} + +function pluginLabelFromUrl(value) { + const raw = cleanText(value, 1000); + try { + const url = new URL(raw, "http://localhost"); + const match = url.pathname.match(/\/plugins\/([^/]+)/); + return match ? match[1].replace(/[_-]+/g, " ") : ""; + } catch { + return ""; + } +} + +function pagePathLabel(value) { + const raw = cleanText(value, 1000); + try { + const url = new URL(raw, "http://localhost"); + return url.pathname === "/" ? "Home" : url.pathname.replace(/^\/+/, "").replace(/[/_-]+/g, " "); + } catch { + return ""; + } +} + +function pagePathKey(value) { + const raw = cleanText(value, 1000); + try { + const url = new URL(raw, "http://localhost"); + return url.pathname.replace(/\/+$/, "") || "/"; + } catch { + return ""; + } +} + +function tokenSet(value) { + return new Set( + cleanText(value, 500) + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token.length >= 3) + ); +} + +function jaccardScore(a, b) { + if (!a.size || !b.size) return 0; + let intersection = 0; + for (const token of a) { + if (b.has(token)) intersection += 1; + } + return intersection / new Set([...a, ...b]).size; +} + +function supportSummary(ids, userId) { + const cleanIds = [...new Set((ids || []).filter(Boolean))]; + const counts = new Map(); + const mine = new Set(); + if (!cleanIds.length) { + return { counts, mine }; + } + const placeholders = cleanIds.map(() => "?").join(","); + db.prepare( + `SELECT feedback_id, COUNT(*) AS count FROM feedback_support WHERE feedback_id IN (${placeholders}) GROUP BY feedback_id` + ) + .all(...cleanIds) + .forEach((row) => counts.set(row.feedback_id, row.count)); + if (userId) { + db.prepare( + `SELECT feedback_id FROM feedback_support WHERE user_id = ? AND feedback_id IN (${placeholders})` + ) + .all(userId, ...cleanIds) + .forEach((row) => mine.add(row.feedback_id)); + } + return { counts, mine }; +} + +function parseDateBoundary(value, edge) { + const raw = cleanText(value, 40); + if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null; + const date = new Date(`${raw}T${edge === "end" ? "23:59:59.999" : "00:00:00.000"}`); + const time = date.getTime(); + return Number.isFinite(time) ? time : null; +} + +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, {}) : {}, + screenshot: row.screenshot_path + ? { + path: row.screenshot_path, + mime: row.screenshot_mime || "image/png", + size: row.screenshot_size || 0 + } + : null, + attachments: attachmentsFor(row.id), + 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 attachmentsFor(feedbackId) { + return db + .prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? ORDER BY created_at ASC") + .all(feedbackId) + .map((row) => ({ + id: row.id, + storage_path: row.storage_path, + original_name: row.original_name || "attachment", + mime: row.mime, + size: row.size, + kind: row.kind, + created_at: row.created_at + })); +} + +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 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), + dom_snapshot: cleanText(value.dom_snapshot, 6000), + screenshot_mode: cleanText(value.screenshot_mode, 40), + screenshot_source: cleanText(value.screenshot_source, 40) + }; +} + +function normalizeScreenshot(value = {}) { + const pathValue = cleanText(value.path, 500); + const mime = cleanText(value.mime, 80); + const size = Number(value.size || 0); + if (!pathValue) { + return { path: null, mime: null, size: null }; + } + if (!/^feedback\/screenshots\/[a-zA-Z0-9_.-]+$/.test(pathValue)) { + throw new Error("Invalid screenshot storage path."); + } + if (!["image/png", "image/jpeg", "image/webp"].includes(mime)) { + throw new Error("Unsupported screenshot type."); + } + if (!Number.isFinite(size) || size <= 0 || size > 8 * 1024 * 1024) { + throw new Error("Screenshot file size is invalid."); + } + return { path: pathValue, mime, size }; +} + +function normalizeAttachments(values = []) { + const entries = Array.isArray(values) ? values : []; + return entries.map((value) => { + const pathValue = cleanText(value.path, 500); + const mime = cleanText(value.mime, 80); + const size = Number(value.size || 0); + if (!/^feedback\/attachments\/[a-zA-Z0-9_.-]+$/.test(pathValue)) { + throw new Error("Invalid attachment storage path."); + } + if (!["image/png", "image/jpeg", "image/webp", "application/pdf", "text/plain"].includes(mime)) { + throw new Error("Unsupported attachment type."); + } + if (!Number.isFinite(size) || size <= 0 || size > 8 * 1024 * 1024) { + throw new Error("Attachment file size is invalid."); + } + return { + path: pathValue, + mime, + size, + original_name: cleanText(value.original_name, 240) || "attachment", + kind: "attachment" + }; + }); +} + +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", + public_comment: "Community 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, + cleanupFeedback, + deleteFeedback, + createFeedback, + findSimilarFeedback, + feedbackOptions, + getFeedbackForAdmin, + getFeedbackAttachment, + getFeedbackForSubmitter, + getFeedbackForViewer, + listFeedbackForAdmin, + listMyFeedback, + listPublicFeedback, + markFeedbackViewed, + notificationSummary, + supportFeedback, + addSubmitterComment +}; diff --git a/src/web/public/app.js b/src/web/public/app.js index 4a791ba..cd4b410 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -945,4 +945,785 @@ 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; + let contextClipboardText = ""; + let contextTooltipTimer = null; + let contextTooltip = null; + let activeFeedbackTarget = null; + let activeFeedbackMetadata = {}; + let attachedScreenshotBlob = null; + let attachedScreenshotUrl = ""; + let attachedScreenshotSource = ""; + let attachedScreenshotName = "feedback-screenshot.png"; + let feedbackSimilarTimer = null; + let feedbackSimilarController = 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 scopeLabelForValue = (value) => { + const option = (feedbackOptions.scopes || []).find((entry) => entry.value === value); + return option?.label || value; + }; + + const screenshotShortcutHint = () => { + const platform = `${navigator.userAgentData?.platform || navigator.platform || ""}`.toLowerCase(); + if (platform.includes("mac")) { + return "Press Shift + Command + 4 to take a selected-area screenshot"; + } + if (platform.includes("linux")) { + return "Press Print Screen or Shift + Print Screen to take a screenshot"; + } + if (platform.includes("iphone") || platform.includes("ipad")) { + return "Use your device screenshot buttons, then choose the saved image"; + } + if (platform.includes("android")) { + return "Use your device screenshot buttons, then choose the saved image"; + } + return "Press Win + Shift + S to take a snippet screenshot"; + }; + + if (feedbackForm) { + const keybindHint = feedbackForm.querySelector("[data-feedback-screenshot-keybind]"); + if (keybindHint) { + keybindHint.textContent = screenshotShortcutHint(); + } + } + + 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 generatedScopeLabel = (scope, metadata = {}) => { + const pageLabel = currentPageLabel() || "this page"; + const elementLabel = metadata.label || metadata.text || metadata.aria_label || metadata.title || metadata.selector; + if (scope === "element") return elementLabel ? `Clicked element: ${elementLabel}` : `Clicked element on ${pageLabel}`; + if (scope === "feature") return `Feature/page: ${pageLabel}`; + if (scope === "plugin") { + const pluginMatch = window.location.pathname.match(/\/plugins\/([^/]+)/); + return pluginMatch ? `Plugin: ${pluginMatch[1].replaceAll("_", " ")}` : `Plugin-related feedback on ${pageLabel}`; + } + if (scope === "system") return `System area: ${pageLabel}`; + if (scope === "other") return `Other feedback on ${pageLabel}`; + return `Whole page: ${pageLabel}`; + }; + + const updateGeneratedScopeLabel = () => { + if (!feedbackForm) return; + const scopeSelect = feedbackForm.querySelector("[data-feedback-scope-type]"); + const scopeLabel = feedbackForm.querySelector("[data-feedback-scope-label]"); + if (!scopeSelect || !scopeLabel) return; + scopeLabel.value = generatedScopeLabel(scopeSelect.value, activeFeedbackMetadata); + }; + + const clearFeedbackHighlight = () => { + highlightedFeedbackTarget?.classList?.remove("feedback-target-highlight"); + highlightedFeedbackTarget = null; + }; + + const openFeedbackModal = ({ scope = "page", target = null } = {}) => { + if (!feedbackModal || !feedbackForm) return; + hideContextMenu(); + clearFeedbackHighlight(); + feedbackForm.reset(); + clearAttachedScreenshot(); + 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) : {}; + activeFeedbackTarget = target; + activeFeedbackMetadata = metadata; + 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 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 = "{}"; + updateGeneratedScopeLabel(); + clearSimilarFeedback(); + 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(); + activeFeedbackTarget = null; + activeFeedbackMetadata = {}; + }; + + 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?.querySelector("[data-feedback-scope-type]")?.addEventListener("change", updateGeneratedScopeLabel); + ["input", "change"].forEach((eventName) => { + feedbackForm?.addEventListener(eventName, (event) => { + if (!event.target.matches?.("input[name='summary'], textarea[name='description'], select[name='category'], select[name='scope_type']")) return; + scheduleSimilarFeedbackLookup(); + }); + }); + + feedbackForm?.addEventListener("click", async (event) => { + const supportButton = event.target.closest("[data-feedback-support-similar]"); + if (!supportButton) return; + event.preventDefault(); + supportButton.disabled = true; + try { + const response = await fetch(`/api/feedback/${encodeURIComponent(supportButton.dataset.feedbackSupportSimilar)}/support`, { + method: "POST", + headers: { Accept: "application/json" } + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "Could not mark this as affecting you."); + supportButton.textContent = `Affects me too (${result.support_count})`; + } catch (error) { + supportButton.disabled = false; + supportButton.textContent = error.message || "Could not mark"; + } + }); + + feedbackForm?.querySelector("[data-feedback-screenshot-file]")?.addEventListener("change", (event) => { + const file = event.target.files?.[0]; + if (file) { + setAttachedScreenshot(file, "upload", file.name || "feedback-screenshot.png"); + } + event.target.value = ""; + }); + + feedbackForm?.querySelector("[data-feedback-paste-screenshot]")?.addEventListener("click", async () => { + await pasteFeedbackScreenshot(); + }); + + feedbackModal?.addEventListener("paste", (event) => { + const file = imageFileFromClipboardData(event.clipboardData); + if (!file) return; + event.preventDefault(); + setAttachedScreenshot(file, "paste", file.name || "feedback-screenshot.png"); + }); + + feedbackForm?.querySelector("[data-feedback-remove-screenshot]")?.addEventListener("click", clearAttachedScreenshot); + + function clearSimilarFeedback() { + feedbackSimilarController?.abort?.(); + feedbackSimilarController = null; + window.clearTimeout(feedbackSimilarTimer); + const box = feedbackForm?.querySelector("[data-feedback-similar]"); + const list = feedbackForm?.querySelector("[data-feedback-similar-list]"); + if (box) box.hidden = true; + if (list) list.replaceChildren(); + } + + function scheduleSimilarFeedbackLookup() { + window.clearTimeout(feedbackSimilarTimer); + feedbackSimilarTimer = window.setTimeout(loadSimilarFeedback, 450); + } + + async function loadSimilarFeedback() { + if (!feedbackForm) return; + const summary = feedbackForm.querySelector("input[name='summary']")?.value || ""; + const description = feedbackForm.querySelector("textarea[name='description']")?.value || ""; + if (summary.trim().length < 6 && description.trim().length < 12) { + clearSimilarFeedback(); + return; + } + feedbackSimilarController?.abort?.(); + feedbackSimilarController = new AbortController(); + const params = new URLSearchParams({ + summary, + description, + category: feedbackForm.querySelector("select[name='category']")?.value || "", + scope_type: feedbackForm.querySelector("select[name='scope_type']")?.value || "", + current_url: window.location.href, + limit: "4" + }); + try { + const response = await fetch(`/api/feedback/similar?${params.toString()}`, { + headers: { Accept: "application/json" }, + signal: feedbackSimilarController.signal + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "Could not load similar feedback."); + renderSimilarFeedback(result.matches || []); + } catch (error) { + if (error.name !== "AbortError") { + renderSimilarFeedback([]); + } + } + } + + function renderSimilarFeedback(matches) { + const box = feedbackForm?.querySelector("[data-feedback-similar]"); + const list = feedbackForm?.querySelector("[data-feedback-similar-list]"); + if (!box || !list) return; + list.replaceChildren(); + if (!matches.length) { + box.hidden = true; + return; + } + matches.forEach((item) => { + const row = document.createElement("article"); + row.className = "feedback-similar-item"; + const title = document.createElement("strong"); + title.textContent = item.summary; + const meta = document.createElement("span"); + meta.className = "hint"; + meta.textContent = `${item.category_label} · ${item.scope_label_display} · ${item.status_label} · ${item.support_count || 0} also affected`; + const actions = document.createElement("div"); + actions.className = "button-group"; + const open = document.createElement("a"); + open.className = "button subtle"; + open.href = `/feedback?id=${encodeURIComponent(item.id)}`; + open.textContent = "Open"; + const support = document.createElement("button"); + support.type = "button"; + support.className = "button subtle"; + support.dataset.feedbackSupportSimilar = item.id; + support.disabled = Boolean(item.supported_by_me); + support.textContent = item.supported_by_me ? "Affects me too" : "This affects me too"; + actions.append(open, support); + row.append(title, meta, actions); + list.append(row); + }); + box.hidden = false; + } + + 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 includeDom = feedbackForm.querySelector("[data-feedback-include-dom]")?.checked; + const formData = new FormData(feedbackForm); + formData.set("target_metadata", JSON.stringify(parseJson(formData.get("target_metadata"), {}))); + formData.set("diagnostics", JSON.stringify({ + ...(includeBrowser + ? { + user_agent: navigator.userAgent, + viewport: `${window.innerWidth}x${window.innerHeight}`, + language: navigator.language || "" + } + : {}), + ...(includeDom + ? { dom_snapshot: sanitizedDomSnapshot(activeFeedbackTarget || document.querySelector("main") || document.body) } + : {}), + ...(attachedScreenshotBlob + ? { + screenshot_mode: "user_attached", + screenshot_source: attachedScreenshotSource + } + : {}) + })); + if (attachedScreenshotBlob) { + formData.set("screenshot", attachedScreenshotBlob, attachedScreenshotName); + } + submitter.disabled = true; + if (status) status.textContent = "Submitting feedback..."; + try { + const response = await fetch("/api/feedback", { + method: "POST", + headers: { Accept: "application/json" }, + body: formData + }); + 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; + } + }); + + function sanitizedDomSnapshot(root) { + const lines = []; + const maxNodes = 80; + const maxDepth = 4; + const skipTags = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"]); + const walk = (node, depth = 0) => { + if (!node || lines.length >= maxNodes || depth > maxDepth) return; + if (node.nodeType !== 1 || skipTags.has(node.tagName)) return; + const indent = " ".repeat(depth); + const tag = node.tagName.toLowerCase(); + const attrs = []; + if (node.id) attrs.push(`#${node.id.slice(0, 60)}`); + if (node.classList?.length) attrs.push(`.${Array.from(node.classList).slice(0, 3).join(".").slice(0, 80)}`); + const role = node.getAttribute?.("role"); + const label = node.getAttribute?.("aria-label") || node.getAttribute?.("title"); + if (role) attrs.push(`role=${role.slice(0, 40)}`); + if (label) attrs.push(`label="${safeText(label, 80)}"`); + const sensitive = node.matches?.(sensitiveSelector) || ["INPUT", "TEXTAREA", "SELECT"].includes(node.tagName); + const text = sensitive ? "" : safeElementText(node); + lines.push(`${indent}<${tag}${attrs.length ? ` ${attrs.join(" ")}` : ""}>${text ? ` ${safeText(text, 120)}` : ""}`); + Array.from(node.children || []).slice(0, 10).forEach((child) => walk(child, depth + 1)); + }; + walk(root); + return lines.join("\n").slice(0, 6000); + } + + function clearAttachedScreenshot() { + if (attachedScreenshotUrl) { + URL.revokeObjectURL(attachedScreenshotUrl); + } + attachedScreenshotBlob = null; + attachedScreenshotUrl = ""; + attachedScreenshotSource = ""; + attachedScreenshotName = "feedback-screenshot.png"; + const preview = feedbackForm?.querySelector("[data-feedback-screenshot-preview]"); + const image = feedbackForm?.querySelector("[data-feedback-screenshot-image]"); + const meta = feedbackForm?.querySelector("[data-feedback-screenshot-meta]"); + const removeButton = feedbackForm?.querySelector("[data-feedback-remove-screenshot]"); + const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]"); + if (preview) preview.hidden = true; + if (image) image.removeAttribute("src"); + if (meta) meta.textContent = ""; + if (removeButton) removeButton.hidden = true; + if (screenshotStatus) screenshotStatus.textContent = ""; + } + + async function pasteFeedbackScreenshot() { + const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]"); + if (screenshotStatus) { + screenshotStatus.textContent = "Looking for an image in the clipboard..."; + } + try { + if (!navigator.clipboard?.read || !window.isSecureContext) { + throw new Error("Clipboard image read is not available."); + } + const items = await navigator.clipboard.read(); + for (const item of items) { + const type = item.types.find((entry) => entry.startsWith("image/")); + if (type) { + const blob = await item.getType(type); + setAttachedScreenshot(blob, "paste", `feedback-screenshot${screenshotExtension(type)}`); + return; + } + } + throw new Error("No image was found in the clipboard."); + } catch (error) { + if (screenshotStatus) { + screenshotStatus.textContent = `${error.message || "Clipboard image could not be read."} You can also copy a screenshot and press Ctrl+V while this dialog is open.`; + } + } + } + + function imageFileFromClipboardData(data) { + const files = Array.from(data?.files || []); + const file = files.find((entry) => entry.type?.startsWith("image/")); + if (file) return file; + const items = Array.from(data?.items || []); + const item = items.find((entry) => entry.type?.startsWith("image/")); + return item?.getAsFile?.() || null; + } + + function screenshotExtension(mime) { + if (mime === "image/jpeg") return ".jpg"; + if (mime === "image/webp") return ".webp"; + return ".png"; + } + + function screenshotLabel(source) { + return source === "paste" ? "Pasted screenshot" : "Uploaded screenshot"; + } + + function setAttachedScreenshot(blob, source, name = "feedback-screenshot.png") { + const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]"); + if (!["image/png", "image/jpeg", "image/webp"].includes(blob.type)) { + if (screenshotStatus) screenshotStatus.textContent = "Only PNG, JPEG, or WebP screenshots can be attached."; + return; + } + if (blob.size > 8 * 1024 * 1024) { + if (screenshotStatus) screenshotStatus.textContent = "Screenshot must be 8 MB or smaller."; + return; + } + clearAttachedScreenshot(); + attachedScreenshotBlob = blob; + attachedScreenshotUrl = URL.createObjectURL(blob); + attachedScreenshotSource = source; + attachedScreenshotName = name || `feedback-screenshot${screenshotExtension(blob.type)}`; + const preview = feedbackForm?.querySelector("[data-feedback-screenshot-preview]"); + const image = feedbackForm?.querySelector("[data-feedback-screenshot-image]"); + const meta = feedbackForm?.querySelector("[data-feedback-screenshot-meta]"); + const removeButton = feedbackForm?.querySelector("[data-feedback-remove-screenshot]"); + const status = feedbackForm?.querySelector("[data-feedback-screenshot-status]"); + if (image && attachedScreenshotUrl) { + image.src = attachedScreenshotUrl; + } + if (meta) { + const sizeKb = Math.max(1, Math.round(blob.size / 1024)); + meta.textContent = `${screenshotLabel(source)} · ${blob.type.replace("image/", "").toUpperCase()} · ${sizeKb} KB`; + } + if (preview) preview.hidden = !attachedScreenshotUrl; + if (removeButton) removeButton.hidden = false; + if (status) status.textContent = "Screenshot attached. Review the preview and remove it if it contains sensitive information."; + } + + const editableElementForTarget = (target) => { + const element = target?.closest?.("input, textarea, [contenteditable='true']"); + if (!element || element.matches("[readonly], [disabled]")) return null; + const inputType = String(element.getAttribute("type") || "").toLowerCase(); + if (["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(inputType)) { + return null; + } + return element; + }; + + const selectedTextForTarget = (target) => { + const editable = editableElementForTarget(target); + if (editable && typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") { + return editable.value.slice(editable.selectionStart, editable.selectionEnd); + } + return window.getSelection?.().toString() || ""; + }; + + const hasInputSelection = (target) => { + const editable = editableElementForTarget(target); + if (!editable) return false; + if (typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") { + return editable.selectionEnd > editable.selectionStart; + } + const selection = window.getSelection?.(); + return Boolean(selection && !selection.isCollapsed && editable.contains(selection.anchorNode)); + }; + + const contextImageForTarget = (target) => target?.closest?.("img") || null; + + const readClipboardText = async () => { + try { + if (!navigator.clipboard?.readText || !window.isSecureContext) return ""; + return await navigator.clipboard.readText(); + } catch { + return ""; + } + }; + + const setContextActionEnabled = (action, enabled) => { + const button = contextMenu?.querySelector(`[data-context-action="${action}"]`); + if (!button) return; + button.setAttribute("aria-disabled", enabled ? "false" : "true"); + button.classList.toggle("is-disabled", !enabled); + button.tabIndex = enabled ? 0 : -1; + }; + + const enabledContextButtons = () => + Array.from(contextMenu?.querySelectorAll("[data-context-action]:not(.is-disabled)") || []); + + const focusContextButton = (offset) => { + const buttons = enabledContextButtons(); + if (!buttons.length) return; + const current = buttons.indexOf(document.activeElement); + const next = current === -1 ? 0 : (current + offset + buttons.length) % buttons.length; + buttons[next].focus(); + }; + + const updateContextMenuItems = async (target) => { + if (!contextMenu) return; + const active = document.activeElement; + const pasteTarget = editableElementForTarget(active); + const selectedText = selectedTextForTarget(target).trim(); + const image = contextImageForTarget(target); + contextClipboardText = pasteTarget ? await readClipboardText() : ""; + setContextActionEnabled("back", window.history.length > 1); + setContextActionEnabled("forward", Boolean(window.navigation?.canGoForward)); + setContextActionEnabled("copy", Boolean(selectedText || image)); + setContextActionEnabled("cut", hasInputSelection(target)); + setContextActionEnabled("paste", Boolean(pasteTarget && contextClipboardText.length > 0)); + setContextActionEnabled("link", true); + setContextActionEnabled("reload", true); + setContextActionEnabled("feedback", true); + }; + + const showContextMenu = async (event) => { + if (!contextMenu || event.shiftKey) return; + event.preventDefault(); + contextTarget = event.target; + contextMenu.hidden = false; + await updateContextMenuItems(contextTarget); + 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(.is-disabled)")?.focus(); + }; + + function hideContextMenu() { + if (!contextMenu) return; + contextMenu.hidden = true; + hideContextTooltip(); + } + + 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 copyImage = async (image) => { + if (!image) return false; + try { + if (!navigator.clipboard?.write || !window.ClipboardItem || !window.isSecureContext) return false; + const response = await fetch(image.currentSrc || image.src); + const blob = await response.blob(); + await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]); + return true; + } catch { + return false; + } + }; + + const insertTextIntoEditable = (editable, text) => { + if (!editable || !text) return; + editable.focus(); + if (typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") { + const start = editable.selectionStart; + const end = editable.selectionEnd; + editable.setRangeText(text, start, end, "end"); + editable.dispatchEvent(new Event("input", { bubbles: true })); + return; + } + document.execCommand("insertText", false, text); + }; + + const runContextAction = async (action, button) => { + if (button?.classList.contains("is-disabled") || button?.getAttribute("aria-disabled") === "true") return; + const editable = editableElementForTarget(contextTarget) || editableElementForTarget(document.activeElement); + try { + if (action === "back") window.history.back(); + if (action === "forward") { + if (window.navigation?.canGoForward) window.history.forward(); + } + if (action === "copy") { + const selectedText = selectedTextForTarget(contextTarget); + if (selectedText) await copyText(selectedText); + else await copyImage(contextImageForTarget(contextTarget)); + } + if (action === "cut" && editable) document.execCommand("cut"); + if (action === "paste" && editable) { + const text = contextClipboardText || await readClipboardText(); + insertTextIntoEditable(editable, 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, button); + }); + contextMenu?.addEventListener("pointerover", (event) => { + const button = event.target.closest("[data-context-action]"); + if (!button || !contextMenu.contains(button)) return; + window.clearTimeout(contextTooltipTimer); + contextTooltipTimer = window.setTimeout(() => showContextTooltip(button), 1000); + }); + contextMenu?.addEventListener("pointerout", (event) => { + const button = event.target.closest("[data-context-action]"); + if (!button) return; + const next = event.relatedTarget; + if (next && (button.contains(next) || contextTooltip?.contains(next))) return; + window.clearTimeout(contextTooltipTimer); + hideContextTooltip(); + }); + document.addEventListener("keydown", (event) => { + if (!contextMenu?.hidden && contextMenu?.contains(document.activeElement)) { + if (event.key === "ArrowDown") { + event.preventDefault(); + focusContextButton(1); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + focusContextButton(-1); + return; + } + if (event.key === "Home") { + event.preventDefault(); + enabledContextButtons()[0]?.focus(); + return; + } + if (event.key === "End") { + event.preventDefault(); + enabledContextButtons().at(-1)?.focus(); + return; + } + if (event.key === "Enter" || event.key === " ") { + const button = document.activeElement?.closest?.("[data-context-action]"); + if (button) { + event.preventDefault(); + runContextAction(button.dataset.contextAction, button); + return; + } + } + if (event.key === "Tab") { + event.preventDefault(); + focusContextButton(event.shiftKey ? -1 : 1); + return; + } + } + if (event.key === "Escape") { + hideContextMenu(); + closeFeedbackModal(); + } + }); + window.addEventListener("blur", hideContextMenu); + + function showContextTooltip(button) { + const text = button?.dataset.contextTooltip || ""; + if (!text) return; + hideContextTooltip(); + contextTooltip = document.createElement("div"); + contextTooltip.className = "lumi-context-tooltip"; + contextTooltip.textContent = text; + contextTooltip.setAttribute("role", "tooltip"); + document.body.append(contextTooltip); + const buttonRect = button.getBoundingClientRect(); + const tooltipRect = contextTooltip.getBoundingClientRect(); + const padding = 8; + let left = buttonRect.right + padding; + let top = buttonRect.top + (buttonRect.height - tooltipRect.height) / 2; + if (left + tooltipRect.width > window.innerWidth - padding) { + left = buttonRect.left - tooltipRect.width - padding; + } + left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding)); + top = Math.max(padding, Math.min(top, window.innerHeight - tooltipRect.height - padding)); + contextTooltip.style.left = `${left}px`; + contextTooltip.style.top = `${top}px`; + } + + function hideContextTooltip() { + window.clearTimeout(contextTooltipTimer); + contextTooltip?.remove(); + contextTooltip = null; + } })(); diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 8d81f74..5d9ad15 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -351,6 +351,30 @@ button:disabled { width: 100%; } +.button-group { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--lumi-space-2); + align-items: center; +} + +.form-grid .field.button-group, +.field.button-group { + flex-direction: row; +} + +.button-group.centered { + justify-content: center; +} + +.button-group .button, +.button-group button.button, +.button-group input[type="submit"].button, +.button-group .lumi-state-btn { + flex: 0 0 auto; +} + .lumi-state-btn-spinner { width: 1em; height: 1em; @@ -1107,6 +1131,16 @@ input[type="color"] { box-shadow: var(--lumi-shadow-lg); } +.modal.feedback-modal { + width: min(50vw, 60rem); + min-width: min(92vw, 45rem); + max-height: 75vh; +} + +.feedback-modal .modal-actions { + justify-content: center; +} + .modal-header h2, .modal-header h3 { margin-bottom: 0; @@ -1246,6 +1280,19 @@ details > summary { padding: var(--lumi-space-3); } + .feedback-modal-backdrop { + align-items: stretch; + padding: var(--lumi-space-2); + } + + .modal.feedback-modal { + width: 100%; + min-width: 0; + max-height: calc(100dvh - (var(--lumi-space-2) * 2)); + min-height: calc(100dvh - (var(--lumi-space-2) * 2)); + padding: var(--lumi-space-3); + } + .hero { padding: var(--lumi-space-4); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 6a75d57..9486f90 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1255,6 +1255,382 @@ 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-submit-status { + min-height: 20px; + color: var(--ink-soft); + font-weight: 700; +} + +.feedback-screenshot-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: center; +} + +.feedback-screenshot-actions label.button { + margin: 0; + cursor: pointer; +} + +.feedback-screenshot-guide { + display: grid; + gap: 4px; + margin: 2px 0 10px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--sea) 35%, var(--border)); + border-radius: 12px; + background: color-mix(in srgb, var(--sea) 9%, var(--surface-2)); +} + +.feedback-screenshot-guide p { + margin: 0; + max-width: none; +} + +.feedback-screenshot-preview { + display: inline-flex; + gap: 10px; + align-items: center; + width: fit-content; + max-width: 100%; + margin-top: 10px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-2); +} + +.feedback-screenshot-preview[hidden] { + display: none; +} + +.feedback-screenshot-preview img { + width: auto; + max-width: min(260px, 42vw); + max-height: 160px; + border-radius: 8px; + object-fit: contain; + background: var(--surface-3); +} + +.feedback-screenshot-preview img:not([src]) { + display: none; +} + +.feedback-similar { + padding: 12px; + border: 1px solid color-mix(in srgb, var(--sun) 40%, var(--border)); + border-radius: 12px; + background: color-mix(in srgb, var(--sun) 8%, var(--surface-2)); +} + +.feedback-similar-list { + display: grid; + gap: 8px; +} + +.feedback-similar-item { + display: grid; + gap: 6px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-2); +} + +.feedback-attachment-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.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, +.feedback-status-closed { + 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); +} + +.lumi-context-menu button.is-disabled { + color: var(--ink-soft); + cursor: not-allowed; + opacity: 0.48; +} + +.lumi-context-menu button.is-disabled:hover, +.lumi-context-menu button.is-disabled:focus { + background: transparent; +} + +.lumi-context-tooltip { + position: fixed; + z-index: 130; + max-width: min(280px, calc(100vw - 16px)); + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-3); + color: var(--ink); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26); + font-size: 13px; + line-height: 1.35; + pointer-events: none; +} + +.checkbox-inline { + display: inline-flex; + gap: 8px; + align-items: center; +} + .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); @@ -1518,6 +1894,33 @@ body .modal-backdrop.destructive-confirm-modal { } @media (max-width: 900px) { + .feedback-modal .form-grid { + grid-template-columns: 1fr; + } + + .feedback-modal .field.full { + grid-column: auto; + } + + .feedback-screenshot-actions, + .feedback-modal .modal-actions { + align-items: stretch; + flex-direction: column; + } + + .feedback-screenshot-actions .button, + .feedback-modal .modal-actions .button { + width: 100%; + } + + .feedback-screenshot-preview { + width: 100%; + } + + .feedback-screenshot-preview img { + max-width: 100%; + } + .app-shell { grid-template-columns: 1fr; } diff --git a/src/web/server.js b/src/web/server.js index ab5a631..7c74b2b 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -110,6 +110,25 @@ const { publishWebEvent, subscribeWebEvents } = require("../services/web-events"); +const { + addSubmitterComment, + adminUpdateFeedback, + cleanupFeedback, + createFeedback, + deleteFeedback, + findSimilarFeedback, + feedbackOptions, + getFeedbackForSubmitter, + getFeedbackForAdmin, + getFeedbackAttachment, + getFeedbackForViewer, + listFeedbackForAdmin, + listMyFeedback, + listPublicFeedback, + markFeedbackViewed, + notificationSummary, + supportFeedback +} = require("../services/feedback"); function ensureSessionSecret() { let secret = getSetting("session_secret"); @@ -1670,6 +1689,60 @@ function setFlash(req, type, message) { req.session.flash = { type, message }; } +function screenshotExtension(mime) { + if (mime === "image/jpeg") return ".jpg"; + if (mime === "image/webp") return ".webp"; + return ".png"; +} + +function feedbackAttachmentExtension(file = {}) { + const original = path.extname(file.originalname || "").slice(0, 12).toLowerCase(); + if ([".png", ".jpg", ".jpeg", ".webp", ".pdf", ".txt"].includes(original)) return original; + if (file.mimetype === "application/pdf") return ".pdf"; + if (file.mimetype === "text/plain") return ".txt"; + return screenshotExtension(file.mimetype); +} + +function normalizeFeedbackRequestBody(body = {}) { + return { + ...body, + target_metadata: parseBodyJson(body.target_metadata), + diagnostics: parseBodyJson(body.diagnostics) + }; +} + +function parseBodyJson(value) { + if (value && typeof value === "object") return value; + try { + const parsed = JSON.parse(String(value || "{}")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function resolveFeedbackScreenshotPath(relativePath, screenshotDir) { + const normalized = String(relativePath || "").replace(/\\/g, "/"); + const prefix = "feedback/screenshots/"; + if (!normalized.startsWith(prefix)) return null; + const fileName = path.basename(normalized.slice(prefix.length)); + if (!fileName || fileName !== normalized.slice(prefix.length)) return null; + const target = path.resolve(screenshotDir, fileName); + const root = path.resolve(screenshotDir); + return target.startsWith(`${root}${path.sep}`) ? target : null; +} + +function resolveFeedbackAttachmentPath(relativePath, attachmentDir) { + const normalized = String(relativePath || "").replace(/\\/g, "/"); + const prefix = "feedback/attachments/"; + if (!normalized.startsWith(prefix)) return null; + const fileName = path.basename(normalized.slice(prefix.length)); + if (!fileName || fileName !== normalized.slice(prefix.length)) return null; + const target = path.resolve(attachmentDir, fileName); + const root = path.resolve(attachmentDir); + return target.startsWith(`${root}${path.sep}`) ? target : null; +} + function getThemeSettings() { return getActiveTheme(); } @@ -2475,6 +2548,10 @@ function createWebServer({ loadPlugins, discordClient }) { const navIconDir = path.join(__dirname, "..", "..", "data", "nav-icons"); fs.mkdirSync(navIconDir, { recursive: true }); app.use("/assets/nav-icons", express.static(navIconDir)); + const feedbackScreenshotDir = path.join(__dirname, "..", "..", "data", "feedback", "screenshots"); + fs.mkdirSync(feedbackScreenshotDir, { recursive: true }); + const feedbackAttachmentDir = path.join(__dirname, "..", "..", "data", "feedback", "attachments"); + fs.mkdirSync(feedbackAttachmentDir, { recursive: true }); const upload = multer ? multer({ dest: uploadDir }) : null; const uploadSingle = (fieldName) => { if (!upload) { @@ -2509,6 +2586,41 @@ function createWebServer({ loadPlugins, discordClient }) { } return navIconUpload.single("icon_file")(req, _res, next); }; + const feedbackScreenshotUpload = + multer && + multer({ + storage: multer.diskStorage({ + destination: (_req, file, cb) => cb(null, file.fieldname === "attachments" ? feedbackAttachmentDir : feedbackScreenshotDir), + filename: (_req, file, cb) => { + const ext = file.fieldname === "attachments" ? feedbackAttachmentExtension(file) : screenshotExtension(file.mimetype); + cb(null, `${crypto.randomUUID()}${ext}`); + } + }), + limits: { fileSize: 8 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (file.fieldname === "screenshot" && ["image/png", "image/jpeg", "image/webp"].includes(file.mimetype)) { + return cb(null, true); + } + if (file.fieldname === "attachments" && ["image/png", "image/jpeg", "image/webp", "application/pdf", "text/plain"].includes(file.mimetype)) { + return cb(null, true); + } + cb(new Error("Only PNG, JPEG, WebP, PDF, or text feedback files are allowed.")); + } + }); + const feedbackUploadFields = (req, res, next) => { + if (!feedbackScreenshotUpload) { + return next(); + } + return feedbackScreenshotUpload.fields([ + { name: "screenshot", maxCount: 1 }, + { name: "attachments", maxCount: 3 } + ])(req, res, (error) => { + if (error) { + req.feedbackUploadError = error.message; + } + next(); + }); + }; const navItems = []; const profileSections = []; @@ -2566,6 +2678,66 @@ function createWebServer({ loadPlugins, discordClient }) { res.status(400).json({ error: error.message }); } }); + app.get("/api/feedback/similar", requireAuth, (req, res) => { + try { + res.json({ + ok: true, + matches: findSimilarFeedback(req.query, { + userId: req.session.user.id, + limit: req.query.limit || 5 + }) + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } + }); + app.post("/api/feedback/:id/support", requireAuth, (req, res) => { + try { + const support_count = supportFeedback(req.params.id, req.session.user); + res.json({ ok: true, support_count }); + } catch (error) { + res.status(400).json({ error: error.message }); + } + }); + app.post("/api/feedback", requireAuth, feedbackUploadFields, (req, res) => { + try { + if (req.feedbackUploadError) { + throw new Error(req.feedbackUploadError); + } + const body = normalizeFeedbackRequestBody(req.body); + const screenshotFile = req.files?.screenshot?.[0] || null; + const attachmentFiles = req.files?.attachments || []; + const entry = createFeedback(body, req.session.user, { + screenshot: screenshotFile + ? { + path: `feedback/screenshots/${screenshotFile.filename}`, + mime: screenshotFile.mimetype, + size: screenshotFile.size + } + : null, + attachments: attachmentFiles.map((file) => ({ + path: `feedback/attachments/${file.filename}`, + mime: file.mimetype, + size: file.size, + original_name: file.originalname + })) + }); + res.status(201).json({ + ok: true, + id: entry.id, + message: "Feedback submitted. You can review it from the Feedback page." + }); + } catch (error) { + [...(req.files?.screenshot || []), ...(req.files?.attachments || [])].forEach((file) => { + if (file?.path) fs.rmSync(file.path, { force: true }); + }); + 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 +2781,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 +3837,89 @@ 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 + ? getFeedbackForViewer(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.post("/feedback/:id/support", requireAuth, (req, res) => { + try { + supportFeedback(req.params.id, req.session.user); + setFlash(req, "success", "Feedback supported."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(req.get("referer") || "/feedback"); + }); + + app.get("/feedback/:id/screenshot", requireAuth, (req, res) => { + const entry = req.session.user?.isAdmin + ? getFeedbackForAdmin(req.params.id) + : getFeedbackForSubmitter(req.params.id, req.session.user.id); + if (!entry?.screenshot?.path) { + return res.status(404).render("error", { + title: "Screenshot not found", + message: "That feedback screenshot is not available." + }); + } + const screenshotPath = resolveFeedbackScreenshotPath(entry.screenshot.path, feedbackScreenshotDir); + if (!screenshotPath || !fs.existsSync(screenshotPath)) { + return res.status(404).render("error", { + title: "Screenshot not found", + message: "That feedback screenshot is no longer available." + }); + } + res.setHeader("Content-Type", entry.screenshot.mime || "image/png"); + res.setHeader("Cache-Control", "private, no-store"); + res.sendFile(screenshotPath); + }); + + app.get("/feedback/:id/attachments/:attachmentId", requireAuth, (req, res) => { + const attachment = getFeedbackAttachment( + req.params.id, + req.params.attachmentId, + req.session.user.id, + Boolean(req.session.user?.isAdmin) + ); + if (!attachment?.storage_path) { + return res.status(404).render("error", { + title: "Attachment not found", + message: "That feedback attachment is not available." + }); + } + const attachmentPath = resolveFeedbackAttachmentPath(attachment.storage_path, feedbackAttachmentDir); + if (!attachmentPath || !fs.existsSync(attachmentPath)) { + return res.status(404).render("error", { + title: "Attachment not found", + message: "That feedback attachment is no longer available." + }); + } + res.setHeader("Content-Type", attachment.mime || "application/octet-stream"); + res.setHeader("Cache-Control", "private, no-store"); + res.setHeader("Content-Disposition", `attachment; filename="${path.basename(attachment.original_name || "attachment")}"`); + res.sendFile(attachmentPath); + }); + app.get("/health", (req, res) => { res.set("Cache-Control", "no-store"); res.json({ ok: true, ts: Date.now() }); @@ -4922,6 +5179,122 @@ 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 || "", + area: req.query.area || "", + submitter: req.query.submitter || "", + date_from: req.query.date_from || "", + date_to: req.query.date_to || "", + needs_action: req.query.needs_action || "", + sort: req.query.sort || "last_activity" + }, + feedbackOptions: feedbackOptions() + }); + }); + + app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => { + try { + const body = { ...req.body }; + if (body.review_action === "finalize") { + body.status = "closed"; + body.status_note = body.status_note || "Finalized and closed."; + } else if (body.review_action === "reopen") { + body.status = "reviewed"; + body.status_note = body.status_note || "Reopened for review."; + } + adminUpdateFeedback(req.params.id, body, req.session.user); + setFlash(req, "success", body.review_action === "finalize" + ? "Feedback finalized and closed." + : body.review_action === "reopen" + ? "Feedback reopened." + : "Feedback updated."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(req.get("referer") || "/admin/feedback"); + }); + + app.post("/admin/feedback/:id/finalize", requireRole("admin"), (req, res) => { + try { + adminUpdateFeedback(req.params.id, { + ...req.body, + status: "closed", + status_note: req.body.status_note || "Finalized and closed." + }, req.session.user); + setFlash(req, "success", "Feedback finalized and closed."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(req.get("referer") || "/admin/feedback"); + }); + + app.post("/admin/feedback/:id/reopen", requireRole("admin"), (req, res) => { + try { + adminUpdateFeedback(req.params.id, { + ...req.body, + status: "reviewed", + status_note: req.body.status_note || "Reopened for review." + }, req.session.user); + setFlash(req, "success", "Feedback reopened."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect(req.get("referer") || "/admin/feedback"); + }); + + app.post("/admin/feedback/:id/delete", requireRole("admin"), (req, res) => { + try { + deleteFeedback(req.params.id, { + deleteScreenshot(relativePath) { + const screenshotPath = resolveFeedbackScreenshotPath(relativePath, feedbackScreenshotDir); + if (screenshotPath) { + fs.rmSync(screenshotPath, { force: true }); + } + }, + deleteAttachment(relativePath) { + const attachmentPath = resolveFeedbackAttachmentPath(relativePath, feedbackAttachmentDir); + if (attachmentPath) { + fs.rmSync(attachmentPath, { force: true }); + } + } + }); + setFlash(req, "success", "Feedback permanently deleted."); + } catch (error) { + setFlash(req, "error", error.message); + } + res.redirect("/admin/feedback"); + }); + + app.post("/admin/feedback/:id/cleanup", requireRole("admin"), (req, res) => { + try { + cleanupFeedback(req.params.id, req.body, req.session.user, { + deleteScreenshot(relativePath) { + const screenshotPath = resolveFeedbackScreenshotPath(relativePath, feedbackScreenshotDir); + if (screenshotPath) { + fs.rmSync(screenshotPath, { force: true }); + } + }, + deleteAttachment(relativePath) { + const attachmentPath = resolveFeedbackAttachmentPath(relativePath, feedbackAttachmentDir); + if (attachmentPath) { + fs.rmSync(attachmentPath, { force: true }); + } + } + }); + setFlash(req, "success", "Feedback data cleaned."); + } 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 +6202,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 +6235,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..e3330f1 --- /dev/null +++ b/src/web/views/admin-feedback.ejs @@ -0,0 +1,274 @@ +<%- 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 close or delete resolved reports." + }) %> +
+ + + + + + + + + + + + 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..c771736 --- /dev/null +++ b/src/web/views/feedback.ejs @@ -0,0 +1,172 @@ +<%- 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() %>
+
+ <% if (!selectedFeedback.is_mine) { %> +
Public feedback

You can mark that this affects you too or add public context without seeing private diagnostics or attachments.

+ <% } %> +
+ 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 %>

+ <% } %> + <% if (selectedFeedback.screenshot) { %> +
+ Attached screenshot +

Open screenshot

+
+ <% } %> + <% if (selectedFeedback.attachments && selectedFeedback.attachments.length) { %> +
+ Attachments + +
+ <% } %> +

Conversation

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

No comments yet.

+ <% } else { %> +
+ <% selectedFeedback.comments.forEach((comment) => { %> + + <% }) %> +
+ <% } %> + <% if (!selectedFeedback.is_mine && !selectedFeedback.supported_by_me) { %> +
+ +
+ <% } else if (selectedFeedback.supported_by_me) { %> + Marked as affecting you too. + <% } %> +
+
+ + + <%= selectedFeedback.is_mine ? "Use this when an admin asks for more detail or you have extra context." : "Use this when you have the same issue or can add useful public detail." %> +
+
+ +
+
+
+<% } %> + +
+

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<% } %> + <% if (item.support_count) { %><%= item.support_count %> also affected<% } %> + <%= item.category_label %><%= item.scope_label_display %> + <%= new Date(item.updated_at).toLocaleString() %> + <% if (!item.is_mine && !item.supported_by_me) { %> +
+ +
+ <% } else if (item.supported_by_me) { %> + Marked as affecting you too. + <% } %> +
+
+ <% } %> +
+<%- include("partials/layout-bottom") %> diff --git a/src/web/views/partials/layout-bottom.ejs b/src/web/views/partials/layout-bottom.ejs index bfca27e..622555b 100644 --- a/src/web/views/partials/layout-bottom.ejs +++ b/src/web/views/partials/layout-bottom.ejs @@ -11,6 +11,131 @@ + <% if (user) { %> + <% const feedbackNotice = typeof feedbackNotifications !== "undefined" ? feedbackNotifications : { total: 0, solved: 0, needs_context: 0, not_worked: 0 }; %> + <% const feedbackOptionData = typeof feedbackOptions !== "undefined" ? feedbackOptions : {}; %> + + + + <% } %>