Lumi/src/services/feedback.js
2026-07-17 22:10:00 +02:00

2155 lines
80 KiB
JavaScript

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."
});
const DEFAULT_FEEDBACK_EXPORT_TOKEN_LIMIT = 8000;
const MAX_FEEDBACK_EXPORT_TOKEN_LIMIT = 24000;
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);
const targetMetadata = sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata);
if (summary.length < 6 && description.length < 12 && !pagePath) return [];
const rows = db
.prepare(
"SELECT id, submitter_id, summary, description, steps_to_reproduce, expected_behavior, actual_behavior, category, severity, scope_type, scope_label, target_metadata_json, 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} ${input.steps_to_reproduce || ""} ${input.expected_behavior || ""} ${input.actual_behavior || ""} ${Object.values(targetMetadata).join(" ")}`);
const matches = rows
.map((row) => {
const rowTarget = parseJson(row.target_metadata_json, {});
const rowTokens = tokenSet(`${row.summary} ${row.description || ""} ${row.steps_to_reproduce || ""} ${row.expected_behavior || ""} ${row.actual_behavior || ""} ${row.scope_label || ""} ${row.page_title || ""} ${Object.values(rowTarget).join(" ")}`);
const samePath = pagePath && pagePath === pagePathKey(row.current_url);
const sameTarget = targetMetadata.path && targetMetadata.path === rowTarget.path;
const sameSelector = targetMetadata.selector && targetMetadata.selector === rowTarget.selector;
const score =
jaccardScore(queryTokens, rowTokens) +
(scopeType && row.scope_type === scopeType ? 0.25 : 0) +
(category && row.category === category ? 0.15 : 0) +
(samePath ? 0.35 : 0) +
(sameTarget ? 0.4 : 0) +
(sameSelector ? 0.25 : 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 mergeFeedback(sourceId, targetId, input = {}, actor) {
const source = getFeedbackForAdmin(sourceId);
const target = getFeedbackForAdmin(targetId);
if (!source || !target) throw new Error("Both feedback items must exist before they can be merged.");
if (source.id === target.id) throw new Error("A feedback item cannot be merged into itself.");
if (source.status === "deleted" || target.status === "deleted") throw new Error("Deleted feedback cannot be merged.");
if (target.merged_into) throw new Error("Choose the final canonical feedback item, not another duplicate.");
const reason = cleanText(input.reason, 1000) || `Merged into ${target.summary}.`;
const now = Date.now();
db.transaction(() => {
db.prepare(
"INSERT INTO feedback_merges (source_feedback_id, target_feedback_id, merged_by, reason, created_at) VALUES (?, ?, ?, ?, ?) " +
"ON CONFLICT(source_feedback_id) DO UPDATE SET target_feedback_id = excluded.target_feedback_id, merged_by = excluded.merged_by, reason = excluded.reason, created_at = excluded.created_at"
).run(source.id, target.id, actor?.id || null, reason, now);
db.prepare(
"UPDATE feedback_entries SET status = 'duplicate', updated_at = ?, last_activity_at = ?, deleted_at = NULL WHERE id = ?"
).run(now, now, source.id);
addStatusHistory(source.id, "duplicate", actor?.id || null, `Merged into feedback ${target.id}: ${reason}`, now);
db.prepare(
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)"
).run(target.id, actor?.id || null, `Merged feedback ${source.id} (${source.summary}). ${reason}`, now);
touchFeedback(target.id, now);
})();
return { source: getFeedbackForAdmin(source.id), target: getFeedbackForAdmin(target.id) };
}
function unmergeFeedback(sourceId, input = {}, actor) {
const source = getFeedbackForAdmin(sourceId);
if (!source?.merged_into) throw new Error("That feedback item is not currently merged.");
const now = Date.now();
const note = cleanText(input.reason, 1000) || "Duplicate merge was undone.";
db.transaction(() => {
db.prepare("DELETE FROM feedback_merges WHERE source_feedback_id = ?").run(source.id);
db.prepare(
"UPDATE feedback_entries SET status = 'reviewed', updated_at = ?, last_activity_at = ?, deleted_at = NULL WHERE id = ?"
).run(now, now, source.id);
addStatusHistory(source.id, "reviewed", actor?.id || null, note, now);
})();
return getFeedbackForAdmin(source.id);
}
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(?) OR lower(feedback_entries.target_metadata_json) LIKE lower(?))"
);
params.push(`%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`);
}
if (filters.route) {
where.push("lower(feedback_entries.current_url) LIKE lower(?)");
params.push(`%${filters.route}%`);
}
if (filters.plugin) {
where.push("(lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.scope_label) LIKE lower(?))");
params.push(`%/plugins/${filters.plugin}%`, `%${filters.plugin}%`);
}
if (filters.target) {
where.push("(lower(feedback_entries.target_metadata_json) LIKE lower(?) OR lower(feedback_entries.scope_label) LIKE lower(?))");
params.push(`%${filters.target}%`, `%${filters.target}%`);
}
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 buildFeedbackJobExport({ ids = [], filters = {}, all = false, tokenLimit } = {}) {
const limit = normalizeFeedbackExportTokenLimit(tokenLimit);
const generatedAt = new Date().toISOString();
const requestedIds = Array.isArray(ids)
? ids.map((id) => cleanText(id, 80)).filter(Boolean)
: [];
const entries = all
? listFeedbackForAdmin(filters).filter(isFeedbackExportEligible)
: requestedIds
.map((id) => getFeedbackForAdmin(id))
.filter(Boolean)
.filter(isFeedbackExportEligible);
const uniqueEntries = [];
const seen = new Set();
for (const entry of entries) {
if (!seen.has(entry.id)) {
seen.add(entry.id);
uniqueEntries.push(entry);
}
}
if (!uniqueEntries.length) {
throw new Error("No eligible feedback items were selected for export.");
}
const exportedFeedback = uniqueEntries.map(feedbackToCodexExport);
const taskFeedback = exportedFeedback.filter(isFeedbackCodexTaskfileEligible);
const debugOnlyFeedback = exportedFeedback.filter((item) => !isFeedbackCodexTaskfileEligible(item));
const reporterByFeedbackId = new Map(uniqueEntries.map((entry) => [entry.id, entry.submitter_id || ""]));
const debugExport = buildFeedbackDebugExport({
exportedFeedback,
generatedAt,
tokenLimit: limit,
all
});
return {
taskfile: buildCompactFeedbackTaskfile({
exportedFeedback: taskFeedback,
debugOnlyFeedback,
generatedAt,
reporterByFeedbackId
}),
debug_export: debugExport
};
}
function buildFeedbackDebugExport({ exportedFeedback, generatedAt, tokenLimit, all }) {
const primary = exportedFeedback[0];
return {
schema: "lumi.feedback.codex_job.v1",
trigger_phrase: "internal-feedback-to-codex-taskfile",
generated_at: generatedAt,
token_context_limit: tokenLimit,
source: {
system: "Lumi feedback",
selection: all ? "all eligible feedback matching current filters" : "selected feedback",
feedback_count: exportedFeedback.length,
feedback_ids: exportedFeedback.map((item) => item.id)
},
privacy: {
stripped: [
"submitter identity",
"private note actor identity",
"raw browser diagnostics",
"local storage paths",
"screenshot and attachment binary data"
],
retained: [
"feedback title, category, severity, status, and scope",
"issue description and reproduction fields",
"public comments and admin replies visible to submitters",
"sanitized private admin work-note context",
"safe page, element, and source references"
]
},
codex_taskfile: {
objective: exportedFeedback.length === 1
? `Resolve Lumi feedback: ${primary.title}`
: `Resolve ${exportedFeedback.length} related Lumi feedback items.`,
context: {
summary: exportedFeedback.length === 1
? compactSentence(primary.description || primary.title, 420)
: `This export contains ${exportedFeedback.length} feedback items selected by an administrator.`,
affected_areas: Array.from(new Set(exportedFeedback.map((item) => item.scope.label).filter(Boolean))),
source_references: exportedFeedback.flatMap((item) => item.source_references).slice(0, 30)
},
requirements: exportedFeedback.map((item) => ({
feedback_id: item.id,
requirement: compactSentence(item.description || item.title, 700),
category: item.category,
severity: item.severity,
status: item.status
})),
acceptance_criteria: exportedFeedback.map((item) => ({
feedback_id: item.id,
criteria: [
"The reported behavior is corrected or the requested improvement is implemented.",
"Existing behavior outside the affected area remains unchanged.",
"Relevant UI text and button language stays clear for non-technical admins."
]
})),
validation_steps: exportedFeedback.map((item) => ({
feedback_id: item.id,
steps: validationStepsForExport(item)
})),
instructions: [
"Use this JSON as the local taskfile for Codex work.",
"Read the codebase before editing and preserve existing Lumi conventions.",
"Do not reintroduce stripped private data into commits, logs, screenshots, or responses.",
"Update TODO.md/taskfile.txt if this work changes tracked progress."
]
},
feedback: exportedFeedback
};
}
function buildCompactFeedbackTaskfile({ exportedFeedback, debugOnlyFeedback = [], generatedAt, reporterByFeedbackId = new Map() }) {
const rawTasks = exportedFeedback.map(feedbackToCompactTask);
const tasks = groupCompactFeedbackTasks(rawTasks, exportedFeedback, reporterByFeedbackId);
const severity = normalizedExportSeverity(tasks.map((task) => task.severity));
const priority = normalizedPriorityForSeverity(severity);
const sourceRefById = new Map(exportedFeedback.map((item, index) => [item.id, `F${index + 1}`]));
const itemById = new Map(exportedFeedback.map((item) => [item.id, item]));
return {
schema: "lumi.feedback.codex_taskfile.v1",
trigger_phrase: "internal-feedback-to-codex-taskfile",
generated_at: generatedAt,
objective: compactExportObjective(tasks),
severity,
priority,
source_feedback: exportedFeedback.map((item) => compactSourceFeedback(item, sourceRefById.get(item.id))),
tasks: tasks.map((task) => compactTaskForOutput(task, sourceRefById, itemById)),
global_constraints: [
"Preserve existing Lumi permissions, feedback visibility, and admin-only export behavior.",
"Do not expose submitter identity, private note authors, raw diagnostics, secrets, local file paths, or binary attachments.",
"Use sanitized private admin work notes only as task context; do not quote or expose sensitive private data.",
"Do not invent exact repository paths; use repo-area hints unless the route-to-file mapping is known.",
"Keep changes scoped to the reported feedback unless broader code paths are directly required."
]
};
}
function compactTaskForOutput(task, sourceRefById, itemById) {
const feedbackIds = Array.from(new Set((task.feedback_ids || [task.feedback_id]).filter(Boolean)));
const sourceRefs = feedbackIds.map((id) => sourceRefById.get(id)).filter(Boolean);
const primary = feedbackIds.map((id) => itemById.get(id)).find(Boolean);
const context = uniqueCompact([
task.pattern_summary,
...(task.affected_areas || []),
...(task.reported_error_messages || []),
...feedbackIds.flatMap((id) => compactContextNotesForFeedback(itemById.get(id) || {})
.map((note) => `${note.kind}: ${note.body}`))
], 10, 420);
const problemIdentifiers = uniqueCompact([
...sourceRefs.map((ref) => `feedback:${ref}`),
...(task.areas_to_inspect || []).map((area) => area.route ? `route:${area.route}` : ""),
...(task.affected_targets || []).map((target) => `target:${target}`)
], 12, 240);
return {
task_id: task.task_id || slugForTaskId(primary?.title || task.objective),
source_refs: sourceRefs,
title: redactPrivateText(primary?.title || task.pattern_summary || task.objective, 180),
severity: task.severity,
priority: task.priority,
...(task.support_count ? { support_count: task.support_count } : {}),
...(context.length ? { context } : {}),
...(problemIdentifiers.length ? { problem_identifiers: problemIdentifiers } : {}),
areas_to_inspect: compactAreasForOutput(task.areas_to_inspect || []),
requirements: compactTaskTextArray(task.requirements, "requirement", 12, 700),
acceptance_criteria: compactTaskTextArray(task.acceptance_criteria, "criteria", 10, 600),
verification: compactTaskTextArray(task.validation, "step", 10, 600),
...((task.risk_notes || []).length ? { risk_notes: uniqueCompact(task.risk_notes, 6, 400) } : {})
};
}
function compactAreasForOutput(areas) {
const seen = new Set();
const output = [];
for (const area of areas) {
const route = redactUrlForExport(area.route);
const exactPaths = uniqueCompact(area.exact_paths || [], 8, 220);
const hints = uniqueCompact(area.repo_area_hints || [], 8, 180);
const key = `${route}|${exactPaths.join("|")}|${hints.join("|")}`;
if (seen.has(key)) continue;
seen.add(key);
output.push({
...(route ? { route } : {}),
...(exactPaths.length ? { exact_paths: exactPaths } : {}),
...(hints.length ? { repo_area_hints: hints } : {})
});
}
return output.slice(0, 10);
}
function compactTaskTextArray(values, key, limit, max) {
return uniqueCompact((values || []).map((item) => (
typeof item === "string" ? item : item?.[key]
)), limit, max);
}
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.");
}
const merged = db.prepare("SELECT target_feedback_id FROM feedback_merges WHERE source_feedback_id = ?").get(id);
const effectiveId = merged?.target_feedback_id || id;
db.prepare(
"INSERT OR IGNORE INTO feedback_support (feedback_id, user_id, created_at) VALUES (?, ?, ?)"
).run(effectiveId, actor.id, Date.now());
return supportSummary([effectiveId], actor.id).counts.get(effectiveId) || 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(() => {
const mergedSources = db.prepare("SELECT source_feedback_id FROM feedback_merges WHERE target_feedback_id = ?").all(id);
for (const source of mergedSources) {
const now = Date.now();
db.prepare("UPDATE feedback_entries SET status = 'reviewed', updated_at = ?, last_activity_at = ? WHERE id = ?")
.run(now, now, source.source_feedback_id);
}
db.prepare("DELETE FROM feedback_merges WHERE source_feedback_id = ? OR target_feedback_id = ?").run(id, id);
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 normalizeFeedbackExportTokenLimit(value) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return DEFAULT_FEEDBACK_EXPORT_TOKEN_LIMIT;
return Math.max(1000, Math.min(MAX_FEEDBACK_EXPORT_TOKEN_LIMIT, parsed));
}
function isFeedbackExportEligible(entry) {
return Boolean(entry && !entry.deleted_at && entry.status !== "deleted");
}
function feedbackToCodexExport(entry) {
return {
id: entry.id,
title: compactSentence(entry.summary, 180),
category: entry.category_label || entry.category,
severity: entry.severity_label || entry.severity,
status: {
value: entry.status,
label: entry.status_label || entry.status,
help: compactSentence(entry.status_help, 240)
},
scope: {
type: entry.scope_type,
type_label: entry.scope_type_label || entry.scope_type,
label: compactSentence(entry.scope_label_display || entry.scope_label, 240)
},
support_count: entry.support_count || 0,
created_at: isoFromMs(entry.created_at),
last_activity_at: isoFromMs(entry.last_activity_at),
description: compactSentence(entry.description, 1800),
steps_to_reproduce: compactSentence(entry.steps_to_reproduce, 1400),
expected_behavior: compactSentence(entry.expected_behavior, 1000),
actual_behavior: compactSentence(entry.actual_behavior, 1000),
public_comments: exportVisibleComments(entry.comments),
context_notes: exportFeedbackContextNotes(entry.comments),
status_history: exportStatusHistory(entry.history),
source_references: exportSourceReferences(entry)
};
}
function isFeedbackCodexTaskfileEligible(item) {
const status = String(item?.status?.value || item?.status || "").toLowerCase();
return !["archived", "closed", "deleted", "duplicate", "fixed", "not_planned", "rejected", "solved", "wont_fix"].includes(status);
}
function feedbackToCompactTask(item) {
const severity = normalizedFeedbackSeverity(item);
const route = routeFromSourceReferences(item.source_references);
const area = compactAreaLabel(item, route);
const summary = redactPrivateText(item.description || item.title, 900);
const expected = redactPrivateText(item.expected_behavior, 500);
const actual = redactPrivateText(item.actual_behavior, 500);
return {
feedback_id: item.id,
feedback_ids: [item.id],
objective: `Resolve feedback ${item.id}: ${redactPrivateText(item.title, 160)}`,
severity,
priority: normalizedPriorityForSeverity(severity),
status: item.status.label,
support_count: item.support_count || 0,
affected_areas: [area].filter(Boolean),
areas_to_inspect: areasToInspectForFeedback(item, route),
requirements: compactRequirementsForFeedback(item, summary, expected, actual),
acceptance_criteria: compactAcceptanceCriteriaForFeedback(item, expected, actual),
validation: compactValidationForFeedback(item, route)
};
}
function groupCompactFeedbackTasks(tasks, exportedFeedback, reporterByFeedbackId = new Map()) {
if (tasks.length <= 1) return tasks;
const itemById = new Map(exportedFeedback.map((item) => [item.id, item]));
const groups = [];
for (const task of tasks) {
const item = itemById.get(task.feedback_id);
const signature = feedbackGroupingSignature(item, task);
const candidate = { task, item, signature };
const group = groups.find((entry) => shouldGroupFeedbackCandidate(candidate, entry.members));
if (group) {
group.members.push(candidate);
} else {
groups.push({ members: [candidate] });
}
}
return groups.map((group) => (
group.members.length === 1
? group.members[0].task
: compactGroupedTask(group.members, reporterByFeedbackId)
));
}
function feedbackGroupingSignature(item, task) {
const route = routeFromSourceReferences(item?.source_references || []);
const targetPaths = feedbackAffectedTargets(item);
const text = feedbackGroupingText(item);
const rootKey = feedbackRootCauseKey(text);
const errors = extractFeedbackErrorMessages(text);
return {
id: item?.id || task.feedback_id,
route,
route_family: routeFamilyKey(route),
target_key: targetPaths.map(normalizeGroupingText).filter(Boolean)[0] || normalizeGroupingText(item?.scope?.label || ""),
root_key: rootKey,
error_messages: errors,
tokens: tokenSet(text),
text
};
}
function shouldGroupFeedbackCandidate(candidate, members) {
return members.some((member) => shouldGroupFeedbackSignatures(candidate.signature, member.signature));
}
function shouldGroupFeedbackSignatures(a, b) {
if (!a || !b) return false;
if (a.error_messages.some((message) => b.error_messages.includes(message))) return true;
if (a.root_key && a.root_key === b.root_key) return true;
const sameRoute = a.route_family && a.route_family === b.route_family;
const sameTarget = a.target_key && b.target_key && (a.target_key === b.target_key || a.target_key.includes(b.target_key) || b.target_key.includes(a.target_key));
const similarity = jaccardScore(a.tokens, b.tokens);
if (sameRoute && sameTarget && similarity >= 0.25) return true;
if (sameRoute && similarity >= 0.42) return true;
return false;
}
function compactGroupedTask(members, reporterByFeedbackId) {
const tasks = members.map((member) => member.task);
const items = members.map((member) => member.item).filter(Boolean);
const feedbackIds = tasks.flatMap((task) => task.feedback_ids || [task.feedback_id]).filter(Boolean);
const severity = normalizedExportSeverity(tasks.map((task) => task.severity));
const affectedTargets = uniqueCompact(items.flatMap(feedbackAffectedTargets), 14, 220);
const reportedErrors = uniqueCompact(members.flatMap((member) => member.signature.error_messages), 8, 240);
const reporters = new Set(feedbackIds.map((id) => reporterByFeedbackId.get(id)).filter(Boolean));
const sourceFeedback = items.map((item) => ({
id: item.id,
title: redactPrivateText(item.title, 160),
human_reference: `Feedback ${item.id}: ${redactPrivateText(item.title, 160)}`
}));
const taskId = slugForTaskId(feedbackRootCauseKey(members.map((member) => member.signature.text).join(" ")) || items.map((item) => item.title).join(" "));
const patternSummary = groupedPatternSummary(items, members, affectedTargets, reportedErrors);
return {
task_id: taskId,
feedback_ids: feedbackIds,
objective: `Resolve grouped feedback pattern: ${patternSummary}`,
severity,
priority: normalizedPriorityForSeverity(severity),
source_feedback_count: feedbackIds.length,
...(reporters.size ? { distinct_reporter_count: reporters.size } : {}),
support_count: tasks.reduce((total, task) => total + (task.support_count || 0), 0),
status: Array.from(new Set(tasks.map((task) => task.status).filter(Boolean))).join(", "),
pattern_summary: patternSummary,
source_feedback: sourceFeedback,
affected_targets: affectedTargets,
reported_error_messages: reportedErrors,
affected_areas: uniqueCompact(tasks.flatMap((task) => task.affected_areas), 12, 180),
areas_to_inspect: mergeAreasToInspect(tasks),
requirements: groupedRequirements(tasks, patternSummary),
acceptance_criteria: groupedAcceptanceCriteria(tasks),
validation: groupedValidation(tasks),
risk_notes: groupedRiskNotes(members)
};
}
function feedbackGroupingText(item) {
if (!item) return "";
return [
item.title,
item.category,
item.scope?.label,
item.description,
item.steps_to_reproduce,
item.expected_behavior,
item.actual_behavior,
...(item.context_notes || []).map((note) => note.body),
...humanReadableSourceReferences(item.source_references || []).flatMap((reference) => Object.values(reference))
].filter(Boolean).join(" ");
}
function feedbackRootCauseKey(text) {
const normalized = normalizeGroupingText(text);
const hasAny = (words) => words.some((word) => normalized.includes(word));
if (hasAny(["delete", "remove", "reset", "archive", "clear", "destructive"]) && hasAny(["timed", "confirmation", "confirm"])) {
return "destructive-confirmation-flow";
}
if (hasAny(["feedback", "modal", "form"]) && hasAny(["close", "reset", "draft", "progress"])) {
return "feedback-modal-draft-retention";
}
if (hasAny(["codex", "taskfile", "export"]) && hasAny(["feedback", "group", "similar", "duplicate", "compress"])) {
return "feedback-codex-export-grouping";
}
if (hasAny(["user", "lookup", "selection", "search"]) && hasAny(["filter", "select", "grant", "permission"])) {
return "shared-user-lookup";
}
if (hasAny(["update", "repo", "zip"]) && hasAny(["git", "cache", "clone", "metadata"])) {
return "repo-update-flow";
}
return "";
}
function extractFeedbackErrorMessages(text) {
const value = redactPrivateText(text, 5000);
const matches = [];
const patterns = [
/\b(?:TypeError|ReferenceError|SyntaxError|RangeError|SqliteError|ENOENT|EACCES|EPERM|Cannot\s+(?:GET|POST|PUT|DELETE|PATCH))[:\s][^.\n]{0,220}/gi,
/\b(?:no such table|no such file or directory|not a git repository|requires? a timed event|timed confirmation)[^.\n]{0,220}/gi
];
for (const pattern of patterns) {
for (const match of value.matchAll(pattern)) {
matches.push(compactSentence(match[0], 240));
}
}
return uniqueCompact(matches, 8, 240);
}
function feedbackAffectedTargets(item) {
const references = humanReadableSourceReferences(item?.source_references || []);
const targets = [];
for (const reference of references) {
if (reference.type === "target_element") {
targets.push(reference.path || reference.selector || reference.label);
} else if (reference.type === "page") {
targets.push(reference.route || reference.title);
}
}
if (item?.scope?.label) targets.push(item.scope.label);
return uniqueCompact(targets, 8, 220);
}
function routeFamilyKey(route) {
const path = pagePathKey(route);
if (!path) return "";
const parts = path.split("/").filter(Boolean);
if (!parts.length) return "/";
if (parts[0] === "plugins" && parts[1]) return `/plugins/${parts[1]}`;
if (parts[0] === "admin" && parts[1]) return `/admin/${parts[1]}`;
return `/${parts[0]}`;
}
function normalizeGroupingText(value) {
return cleanText(value, 1000)
.toLowerCase()
.replace(/https?:\/\/[^\s]+/g, " ")
.replace(/[^a-z0-9/._-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function uniqueCompact(values, limit = 12, max = 180) {
const seen = new Set();
const output = [];
for (const value of values || []) {
const text = redactPrivateText(value, max);
const key = normalizeGroupingText(text);
if (!text || !key || seen.has(key)) continue;
seen.add(key);
output.push(text);
if (output.length >= limit) break;
}
return output;
}
function slugForTaskId(value) {
const slug = normalizeGroupingText(value)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return slug || "grouped-feedback-task";
}
function dedupeObjectsByText(items, key, limit = 12) {
const seen = new Set();
const output = [];
for (const item of items || []) {
const text = redactPrivateText(item?.[key], 900);
const normalized = normalizeGroupingText(text);
if (!text || !normalized || seen.has(normalized)) continue;
seen.add(normalized);
output.push({ ...item, [key]: text });
if (output.length >= limit) break;
}
return output;
}
function groupedPatternSummary(items, members, affectedTargets, reportedErrors) {
const rootKey = members.map((member) => member.signature.root_key).find(Boolean);
if (rootKey === "destructive-confirmation-flow") {
return "Multiple destructive actions appear to share broken timed confirmation wiring.";
}
if (rootKey === "feedback-modal-draft-retention") {
return "Multiple reports point to feedback modal progress being lost or reset too easily.";
}
if (rootKey === "feedback-codex-export-grouping") {
return "Multiple reports point to feedback-to-Codex export producing duplicated or overly verbose tasks.";
}
if (reportedErrors.length) {
return `Multiple reports share the error pattern: ${reportedErrors[0]}`;
}
if (affectedTargets.length) {
return `Multiple reports affect related UI areas, including ${affectedTargets.slice(0, 3).join(", ")}.`;
}
return `Multiple feedback reports appear related: ${items.map((item) => redactPrivateText(item.title, 80)).slice(0, 3).join("; ")}.`;
}
function groupedRequirements(tasks, patternSummary) {
const requirements = [{
feedback_id: tasks.flatMap((task) => task.feedback_ids || [task.feedback_id]).join(","),
requirement: `Fix the shared underlying behavior instead of applying separate one-off fixes: ${patternSummary}`
}];
for (const task of tasks) {
requirements.push(...task.requirements);
}
return dedupeObjectsByText(requirements, "requirement", 14);
}
function groupedAcceptanceCriteria(tasks) {
const criteria = tasks.flatMap((task) => task.acceptance_criteria);
criteria.unshift({
feedback_id: tasks.flatMap((task) => task.feedback_ids || [task.feedback_id]).join(","),
criteria: "All grouped source feedback items are resolved by the same implementation pattern or intentionally separated with a documented reason."
});
return dedupeObjectsByText(criteria, "criteria", 12);
}
function groupedValidation(tasks) {
const validation = tasks.flatMap((task) => task.validation);
validation.unshift({
feedback_id: tasks.flatMap((task) => task.feedback_ids || [task.feedback_id]).join(","),
step: "Verify each affected route/target from the grouped source feedback references."
});
return dedupeObjectsByText(validation, "step", 12);
}
function groupedRiskNotes(members) {
const rootKeys = new Set(members.map((member) => member.signature.root_key).filter(Boolean));
if (rootKeys.size) return [];
return ["Grouped by route/target/text similarity; confirm the shared root cause during code inspection before broad changes."];
}
function compactSourceFeedback(item, ref) {
const routes = uniqueCompact(
humanReadableSourceReferences(item.source_references)
.filter((reference) => reference.type === "page")
.map((reference) => reference.route),
5,
300
);
return {
ref,
id: item.id,
title: redactPrivateText(item.title, 180),
category: item.category,
severity: normalizedFeedbackSeverity(item),
priority: normalizedPriorityForSeverity(normalizedFeedbackSeverity(item)),
status: item.status.label,
scope: redactPrivateText(item.scope.label, 180),
support_count: item.support_count || 0,
...(routes.length ? { routes } : {})
};
}
function compactExportObjective(tasks) {
if (!tasks.length) {
return "No actionable feedback items were selected for the default Codex taskfile.";
}
if (tasks.length === 1) {
return tasks[0].objective;
}
return `Resolve ${tasks.length} actionable Lumi feedback items without duplicating fixes across shared code paths.`;
}
function compactExportContext(tasks, debugOnlyFeedback) {
if (!tasks.length) {
const count = debugOnlyFeedback.length;
return `${count} selected feedback item(s) are closed, duplicate, rejected, solved, or otherwise non-actionable. Use full debug export for review.`;
}
if (tasks.length === 1) {
return `One actionable feedback item affects ${tasks[0].affected_areas.join(", ") || "an unspecified Lumi area"}.`;
}
const areas = Array.from(new Set(tasks.flatMap((task) => task.affected_areas))).slice(0, 6);
return `${tasks.length} actionable feedback items affect ${areas.join(", ") || "multiple Lumi areas"}.`;
}
function compactRequirementsForFeedback(item, summary, expected, actual) {
const requirements = [
{
feedback_id: item.id,
requirement: summary || `Address the reported ${item.category.toLowerCase()} feedback.`
}
];
if (expected) {
requirements.push({
feedback_id: item.id,
requirement: `Match expected behavior: ${expected}`
});
}
if (actual) {
requirements.push({
feedback_id: item.id,
requirement: `Eliminate reported actual behavior: ${actual}`
});
}
for (const note of item.context_notes || []) {
const body = redactPrivateText(note.body, 360);
if (body) {
requirements.push({
feedback_id: item.id,
requirement: `Account for ${note.kind.toLowerCase()}: ${body}`
});
}
}
return requirements.slice(0, 8);
}
function compactAcceptanceCriteriaForFeedback(item, expected, actual) {
const criteria = [];
if (actual) {
criteria.push({
feedback_id: item.id,
criteria: `The reported failure no longer occurs: ${actual}`
});
}
if (expected) {
criteria.push({
feedback_id: item.id,
criteria: `The affected UI or behavior matches the expected outcome: ${expected}`
});
}
const category = String(item.category || "").toLowerCase();
if (category.includes("visual") || category.includes("layout")) {
criteria.push({
feedback_id: item.id,
criteria: "The affected layout remains usable on desktop and mobile viewport widths."
});
} else if (category.includes("accessibility")) {
criteria.push({
feedback_id: item.id,
criteria: "The affected control remains keyboard-accessible and has clear labels or status text."
});
} else if (category.includes("permission") || category.includes("access")) {
criteria.push({
feedback_id: item.id,
criteria: "The fix preserves existing role and permission boundaries."
});
} else if (category.includes("broken") || category.includes("bug") || category.includes("unexpected")) {
criteria.push({
feedback_id: item.id,
criteria: "The affected action gives a visible success or error result instead of silently failing."
});
}
criteria.push({
feedback_id: item.id,
criteria: "No private feedback data, diagnostics, screenshots, tokens, or local paths are exposed."
});
return criteria.slice(0, 5);
}
function compactValidationForFeedback(item, route) {
const validation = [];
const steps = redactPrivateText(item.steps_to_reproduce, 450);
if (steps) {
validation.push({
feedback_id: item.id,
step: `Reproduce from feedback steps and confirm the new behavior: ${steps}`
});
}
if (route) {
validation.push({
feedback_id: item.id,
step: `Manually verify the affected route or page area: ${route}`
});
}
validation.push({
feedback_id: item.id,
step: "Run the relevant repo verification command(s) for the touched web/service files."
});
return validation;
}
function buildCompactRiskNotes(tasks, debugOnlyFeedback) {
const notes = [];
if (debugOnlyFeedback.length) {
notes.push(`${debugOnlyFeedback.length} selected feedback item(s) were excluded from the default taskfile because their statuses are closed, duplicate, rejected, solved, archived, or not planned. They remain available in full debug export.`);
}
for (const note of tasks.flatMap((task) => task.risk_notes || [])) {
notes.push(note);
}
if (tasks.some((task) => task.severity === "critical")) {
notes.push("Critical feedback may involve security, urgent breakage, or sensitive workflows; minimize logging and avoid exposing private data.");
}
if (tasks.some((task) => task.areas_to_inspect.some((area) => area.exact_paths.length === 0))) {
notes.push("Some affected areas only have route or repo-area hints. Inspect the codebase before choosing exact files.");
}
return notes;
}
function normalizedFeedbackSeverity(item) {
const raw = String(item?.severity || item?.category || "").toLowerCase();
if (raw.includes("security") || raw.includes("urgent")) return "critical";
if (raw.includes("broken") || raw.includes("performance")) return "high";
if (raw.includes("confusing") || raw.includes("unexpected") || raw.includes("accessibility") || raw.includes("permission")) return "medium";
return "low";
}
function normalizedExportSeverity(values = []) {
const order = ["low", "medium", "high", "critical"];
return values.reduce((highest, value) => (
order.indexOf(value) > order.indexOf(highest) ? value : highest
), "low");
}
function normalizedPriorityForSeverity(severity) {
return {
critical: "p0",
high: "p1",
medium: "p2",
low: "p3"
}[severity] || "p3";
}
function compactAreaLabel(item, route) {
const scope = compactScopeLabel(item.scope?.label, route);
if (route && scope) return `${scope} (${route})`;
return scope || route || "Unspecified Lumi area";
}
function compactScopeLabel(label, route) {
const scope = redactPrivateText(label, 140);
if (/^clicked element:/i.test(scope) && scope.length > 90) {
return route ? "Clicked element on affected route" : "Clicked element";
}
return scope;
}
function routeFromSourceReferences(references = []) {
const page = references.find((reference) => reference.type === "page" && reference.url);
return redactUrlForExport(page?.url);
}
function mergeAreasToInspect(tasks) {
const map = new Map();
for (const task of tasks) {
const taskFeedbackIds = task.feedback_ids || [task.feedback_id].filter(Boolean);
for (const area of task.areas_to_inspect) {
const key = `${area.route || ""}:${area.label || ""}:${area.repo_area_hints.join("|")}`;
if (!map.has(key)) {
map.set(key, {
...area,
feedback_ids: []
});
}
const areaFeedbackIds = area.feedback_ids?.length ? area.feedback_ids : taskFeedbackIds;
map.get(key).feedback_ids.push(...areaFeedbackIds);
}
}
return Array.from(map.values()).map((area) => ({
...area,
feedback_ids: Array.from(new Set(area.feedback_ids.filter(Boolean)))
}));
}
function areasToInspectForFeedback(item, route) {
const mapped = routeInspectionHints(route);
return [{
feedback_ids: [item.id],
label: compactAreaLabel(item, route),
route: route || null,
exact_paths: mapped.exact_paths,
repo_area_hints: mapped.repo_area_hints,
source_reference: humanReadableSourceReferences(item.source_references)[0] || null
}];
}
function routeInspectionHints(route) {
const normalized = String(route || "").replace(/\/+$/, "") || "/";
const known = [
{
prefix: "/admin/feedback",
exact_paths: [
"src/web/views/admin-feedback.ejs",
"src/web/public/app.js",
"src/web/public/lumi-components.css",
"src/web/server.js",
"src/services/feedback.js"
],
repo_area_hints: ["admin feedback review UI", "feedback service/export routes"]
},
{
prefix: "/feedback",
exact_paths: [
"src/web/views/feedback.ejs",
"src/web/views/partials/layout-bottom.ejs",
"src/web/public/app.js",
"src/web/server.js",
"src/services/feedback.js"
],
repo_area_hints: ["public feedback UI", "feedback submission routes"]
},
{
prefix: "/admin/navigation",
exact_paths: [
"src/web/views/admin-navigation.ejs",
"src/web/public/app.js",
"src/web/public/lumi-components.css",
"src/web/server.js"
],
repo_area_hints: ["admin navigation builder"]
},
{
prefix: "/admin/settings",
exact_paths: [
"src/web/views/admin-settings.ejs",
"src/web/server.js",
"src/services/settings.js"
],
repo_area_hints: ["admin settings UI", "settings persistence"]
},
{
prefix: "/admin/updates",
exact_paths: [
"src/web/views/admin-updates.ejs",
"src/web/public/app.js",
"src/web/server.js",
"src/services/repo-update.js"
],
repo_area_hints: ["admin update UI", "repo update services"]
}
];
const match = known.find((entry) => normalized === entry.prefix || normalized.startsWith(`${entry.prefix}/`));
if (match) {
return {
exact_paths: match.exact_paths,
repo_area_hints: match.repo_area_hints
};
}
if (normalized.startsWith("/plugins/")) {
const slug = normalized.split("/").filter(Boolean)[1] || "";
return {
exact_paths: [],
repo_area_hints: [
slug ? `plugin route /plugins/${slug}` : "plugin route",
"plugin WebUI route/view",
"plugin backend module"
]
};
}
if (normalized.startsWith("/admin")) {
return {
exact_paths: ["src/web/server.js"],
repo_area_hints: ["admin WebUI route", "matching admin EJS view", "shared Lumi component CSS/JS"]
};
}
return {
exact_paths: [],
repo_area_hints: ["matching route/view", "shared Lumi web UI JavaScript", "related service module"]
};
}
function humanReadableSourceReferences(references = []) {
return references
.filter((reference) => ["page", "target_element", "linked_issue", "linked_correction"].includes(reference.type))
.map((reference) => {
if (reference.type === "page") {
return {
type: "page",
title: redactPrivateText(reference.title, 160),
route: redactUrlForExport(reference.url)
};
}
if (reference.type === "target_element") {
const label = redactPrivateText(reference.label || reference.aria_label || reference.title || reference.role || reference.tag, 120);
return {
type: "target_element",
path: redactPrivateText(reference.path, 220),
selector: redactPrivateText(reference.selector, 220),
label: label.length > 90 ? "Clicked element" : label
};
}
return {
type: reference.type,
value: redactPrivateText(reference.value, 220)
};
})
.filter((reference) => Object.values(reference).some(Boolean));
}
function exportVisibleComments(comments = []) {
return comments
.filter((comment) => comment.visible_to_submitter && comment.kind !== "work_note")
.map((comment) => ({
kind: comment.kind_label || comment.kind,
body: compactSentence(comment.body, 900),
created_at: isoFromMs(comment.created_at)
}))
.filter((comment) => comment.body);
}
function exportFeedbackContextNotes(comments = []) {
return comments
.filter((comment) => ["submitter_comment", "public_comment", "admin_reply", "work_note"].includes(comment.kind))
.map((comment) => ({
kind: feedbackContextKindLabel(comment.kind),
visibility: comment.visible_to_submitter ? "submitter_visible" : "admin_private",
body: redactPrivateText(comment.body, 900),
created_at: isoFromMs(comment.created_at)
}))
.filter((comment) => comment.body)
.slice(-8);
}
function compactContextNotesForFeedback(item) {
return (item.context_notes || [])
.map((note) => ({
kind: note.kind,
visibility: note.visibility,
body: redactPrivateText(note.body, 360)
}))
.filter((note) => note.body)
.slice(-5);
}
function feedbackContextKindLabel(kind) {
return {
submitter_comment: "Submitter comment",
public_comment: "Community comment",
admin_reply: "Admin reply to submitter",
work_note: "Private admin work note"
}[kind] || "Feedback note";
}
function exportStatusHistory(history = []) {
return history.map((row) => ({
status: row.status_label || row.status,
note: compactSentence(row.note, 400),
created_at: isoFromMs(row.created_at)
}));
}
function exportSourceReferences(entry) {
const references = [];
const currentUrl = safeUrlReference(entry.current_url);
if (currentUrl) {
references.push({
type: "page",
url: currentUrl,
title: compactSentence(entry.page_title, 240)
});
} else if (entry.page_title) {
references.push({
type: "page",
title: compactSentence(entry.page_title, 240)
});
}
const target = exportTargetReference(entry.target_metadata || {});
if (Object.keys(target).length) {
references.push({ type: "target_element", ...target });
}
if (entry.linked_issue) {
references.push({ type: "linked_issue", value: compactSentence(entry.linked_issue, 500) });
}
if (entry.linked_correction) {
references.push({ type: "linked_correction", value: compactSentence(entry.linked_correction, 500) });
}
if (entry.screenshot) {
references.push({
type: "screenshot_metadata",
mime: entry.screenshot.mime,
size_kb: Math.max(1, Math.round((entry.screenshot.size || 0) / 1024))
});
}
for (const attachment of entry.attachments || []) {
references.push({
type: "attachment_metadata",
name: compactSentence(attachment.original_name, 180),
mime: compactSentence(attachment.mime, 120),
kind: compactSentence(attachment.kind, 80),
size_kb: Math.max(1, Math.round((attachment.size || 0) / 1024))
});
}
return references.filter((reference) => Object.values(reference).some((value) => value !== "" && value !== null && value !== undefined));
}
function exportTargetReference(target = {}) {
const allowed = {
path: 500,
selector: 500,
tag: 40,
role: 80,
label: 220,
text: 320,
aria_label: 220,
title: 220,
heading: 220,
viewport: 80
};
return Object.fromEntries(Object.entries(allowed)
.map(([key, max]) => [key, compactSentence(target[key], max)])
.filter(([, value]) => value));
}
function validationStepsForExport(item) {
const steps = [];
if (item.steps_to_reproduce) {
steps.push(`Reproduce using the supplied steps: ${compactSentence(item.steps_to_reproduce, 500)}`);
}
const pageRef = item.source_references.find((reference) => reference.type === "page");
if (pageRef?.url) {
steps.push(`Verify the affected page/route: ${pageRef.url}`);
}
if (item.actual_behavior || item.expected_behavior) {
steps.push("Compare the fixed behavior against the exported expected and actual behavior fields.");
}
steps.push("Run the repo's relevant verification commands and record any commands that could not be run.");
return steps;
}
function compactSentence(value, max = 500) {
const cleaned = cleanText(value, Math.max(max * 2, max));
if (!cleaned) return "";
if (cleaned.length <= max) return cleaned;
return `${cleaned.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
}
function redactPrivateText(value, max = 500) {
const cleaned = cleanText(value, Math.max(max * 2, max))
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
.replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, "[ip]")
.replace(/\b([a-z0-9_-]*(?:token|secret|session|password|passwd|api[_-]?key|auth)[a-z0-9_-]*)\s*[:=]\s*[^,\s;]+/gi, "$1=[redacted]")
.replace(/([?&](?:token|secret|session|password|passwd|api_key|apikey|auth|code)=)[^&#\s]+/gi, "$1[redacted]")
.replace(/\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+/gi, "[auth header]")
.trim();
if (cleaned.length <= max) return cleaned;
return `${cleaned.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
}
function isoFromMs(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return null;
return new Date(numeric).toISOString();
}
function safeUrlReference(raw) {
const value = cleanText(raw, 1000);
if (!value) return "";
try {
const url = new URL(value, "http://lumi.local");
if (!["http:", "https:"].includes(url.protocol)) return "";
const base = url.origin === "http://lumi.local" ? "" : url.origin;
return `${base}${url.pathname}${url.hash}`;
} catch {
return "";
}
}
function redactUrlForExport(raw) {
const value = cleanText(raw, 1000);
if (!value) return "";
try {
const url = new URL(value, "http://lumi.local");
if (!["http:", "https:"].includes(url.protocol)) return "";
for (const key of [...url.searchParams.keys()]) {
if (/^(?:token|secret|session|password|passwd|api_key|apikey|auth|code)$/i.test(key)) {
url.searchParams.set(key, "[redacted]");
}
}
return `${url.pathname || "/"}${url.search}${url.hash}`;
} catch {
return "";
}
}
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.path || metadata.label || metadata.aria_label || metadata.title || metadata.selector || metadata.text;
if (scopeType === "element") {
return cleanText(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(",");
const related = new Map(cleanIds.map((id) => [id, new Set([id])]));
db.prepare(`SELECT source_feedback_id, target_feedback_id FROM feedback_merges WHERE target_feedback_id IN (${placeholders})`)
.all(...cleanIds)
.forEach((row) => related.get(row.target_feedback_id)?.add(row.source_feedback_id));
const allIds = [...new Set([...related.values()].flatMap((set) => [...set]))];
const allPlaceholders = allIds.map(() => "?").join(",");
const supporters = db.prepare(
`SELECT feedback_id, user_id FROM feedback_support WHERE feedback_id IN (${allPlaceholders})`
).all(...allIds);
for (const [id, relatedIds] of related) {
const users = new Set(supporters.filter((row) => relatedIds.has(row.feedback_id)).map((row) => row.user_id));
if (users.size) counts.set(id, users.size);
if (userId && users.has(userId)) mine.add(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, includeMerge = true }) {
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);
}
if (includeMerge) Object.assign(parsed, mergeContext(row.id, admin));
return parsed;
}
function mergeContext(feedbackId, admin) {
const mergedInto = db.prepare(
"SELECT feedback_merges.*, feedback_entries.summary, feedback_entries.status FROM feedback_merges " +
"JOIN feedback_entries ON feedback_entries.id = feedback_merges.target_feedback_id WHERE source_feedback_id = ?"
).get(feedbackId);
const sourceRows = db.prepare(
"SELECT feedback_entries.* FROM feedback_merges JOIN feedback_entries ON feedback_entries.id = feedback_merges.source_feedback_id " +
"WHERE feedback_merges.target_feedback_id = ? ORDER BY feedback_merges.created_at ASC"
).all(feedbackId);
const reporterIds = new Set();
const ownReporter = db.prepare("SELECT submitter_id FROM feedback_entries WHERE id = ?").get(feedbackId)?.submitter_id;
if (ownReporter) reporterIds.add(ownReporter);
sourceRows.forEach((row) => reporterIds.add(row.submitter_id));
return {
merged_into: mergedInto ? {
id: mergedInto.target_feedback_id,
summary: mergedInto.summary,
status: mergedInto.status,
reason: mergedInto.reason,
created_at: mergedInto.created_at
} : null,
merged_sources: sourceRows.map((source) => {
const hydrated = hydrateFeedback(source, { admin, includeMerge: false });
if (!admin) {
hydrated.screenshot = null;
hydrated.attachments = [];
}
return hydrated;
}),
reporter_count: reporterIds.size
};
}
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),
path: cleanSemanticTargetPath(value.path),
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 cleanSemanticTargetPath(value) {
const raw = cleanText(value, 500);
if (!raw) return "";
const parts = raw
.split(";")
.map((part) => cleanText(part, 140))
.filter(Boolean)
.slice(0, 5);
if (!parts.length) return "";
return parts.join(";");
}
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),
similar_feedback_confirmation: value.similar_feedback_confirmation === "distinct_or_additional_context"
? "distinct_or_additional_context"
: "",
similar_feedback_ids: cleanText(value.similar_feedback_ids, 500)
.split(",")
.map((id) => id.trim())
.filter((id) => /^[a-z0-9-]{8,80}$/i.test(id))
.slice(0, 5)
.join(",")
};
}
function normalizeScreenshot(value = {}) {
if (!value || typeof value !== "object") {
return { path: null, mime: null, size: null };
}
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,
buildFeedbackJobExport,
cleanupFeedback,
deleteFeedback,
createFeedback,
findSimilarFeedback,
feedbackOptions,
getFeedbackForAdmin,
getFeedbackAttachment,
getFeedbackForSubmitter,
getFeedbackForViewer,
listFeedbackForAdmin,
listMyFeedback,
listPublicFeedback,
markFeedbackViewed,
mergeFeedback,
notificationSummary,
supportFeedback,
unmergeFeedback,
addSubmitterComment
};