2155 lines
80 KiB
JavaScript
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
|
|
};
|