<%= item.summary %>
+<%= item.category_label %> · <%= item.scope_label_display %> · <%= new Date(item.updated_at).toLocaleString() %>
+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 }) %>
+ <%= feedbackItems.length %> item(s) shown. <%= item.description %> <%= item.steps_to_reproduce %> <%= item.expected_behavior || "-" %> <%= item.actual_behavior || "-" %> Small, specific reports are easier to review than broad combined lists. Opening this page marks current feedback updates as viewed. <%= item.category_label %> · <%= item.scope_label_display %> · <%= new Date(item.updated_at).toLocaleString() %> <%= selectedFeedback.status_label %>: <%= selectedFeedback.status_help %> You can mark that this affects you too or add public context without seeing private diagnostics or attachments. <%= selectedFeedback.description %> <%= selectedFeedback.steps_to_reproduce %> <%= selectedFeedback.expected_behavior || "-" %> <%= selectedFeedback.actual_behavior || "-" %> <%= selectedFeedback.admin_reply %> No comments yet. <%= comment.body %> This list hides submitter identity and detailed diagnostics.Feedback queue
+
+
+ <%= item.summary %>
+ <%= item.submitter_name || item.submitter_id %> · <%= item.category_label %> · <%= item.scope_label_display %> · <%= item.support_count || 0 %> also affected
+
+ <%= item.status_label %>
+
+ Your feedback notifications
+ My feedback
+ <% if (!myFeedback.length) { %>
+ <%= item.summary %>
+ <%= selectedFeedback.summary %>
+ Conversation
+ <% if (!selectedFeedback.comments.length) { %>
+ Community feedback overview
+
+
+
+
+
+
+
+ <% feedbackList.forEach((item) => { %>
+ Summary
+ Category
+ Scope
+ Status
+ Updated
+
+
+ <% }) %>
+
+
+ <%= item.summary %>
+ <% if (item.is_mine) { %>Mine<% } %>
+ <% if (item.support_count) { %><%= item.support_count %> also affected<% } %>
+
+ <%= item.category_label %>
+ <%= item.scope_label_display %>
+ <%= item.status_label %>
+
+ <%= 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.
+ <% } %>
+
+
Comments and notes
+ <% if (!item.comments.length) { %> +No comments or notes yet.
+ <% } %> + <% item.comments.forEach((comment) => { %> +<%= comment.body %>
+ <%= comment.actor_name || comment.actor_id %> · <%= new Date(comment.created_at).toLocaleString() %><%= comment.visible_to_submitter ? "" : " · private" %> +