Add feedback voting and thumbs capture flow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 09:11:49 -05:00
parent 3db6bdfc3c
commit c0d0d03bce
66 changed files with 18988 additions and 78 deletions

View file

@ -9,6 +9,10 @@ import {
createIssueLabelSchema,
checkoutIssueSchema,
createIssueSchema,
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
upsertIssueFeedbackVoteSchema,
linkIssueApprovalSchema,
issueDocumentKeySchema,
restoreIssueDocumentRevisionSchema,
@ -22,8 +26,10 @@ import {
accessService,
agentService,
executionWorkspaceService,
feedbackService,
goalService,
heartbeatService,
instanceSettingsService,
issueApprovalService,
issueService,
documentService,
@ -49,6 +55,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
const svc = issueService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db);
const feedback = feedbackService(db);
const instanceSettings = instanceSettingsService(db);
const agentsSvc = agentService(db);
const projectsSvc = projectService(db);
const goalsSvc = goalService(db);
@ -69,6 +77,19 @@ export function issueRoutes(db: Db, storage: StorageService) {
};
}
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";
}
function parseDateQuery(value: unknown, field: string) {
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new HttpError(400, `Invalid ${field} query value`);
}
return parsed;
}
async function runSingleFileUpload(req: Request, res: Response) {
await new Promise<void>((resolve, reject) => {
upload.single("file")(req, res, (err: unknown) => {
@ -542,6 +563,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
baseRevisionId: req.body.baseRevisionId ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
createdByRunId: actor.runId ?? null,
});
const doc = result.document;
@ -1153,6 +1175,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
comment = await svc.addComment(id, commentBody, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
});
await logActivity(db, {
@ -1462,6 +1485,87 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.json(comment);
});
router.get("/issues/:id/feedback-votes", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can view feedback votes" });
return;
}
const votes = await feedback.listIssueVotesForUser(id, req.actor.userId ?? "local-board");
res.json(votes);
});
router.get("/issues/:id/feedback-traces", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can view feedback traces" });
return;
}
const targetTypeRaw = typeof req.query.targetType === "string" ? req.query.targetType : undefined;
const voteRaw = typeof req.query.vote === "string" ? req.query.vote : undefined;
const statusRaw = typeof req.query.status === "string" ? req.query.status : undefined;
const targetType = targetTypeRaw ? feedbackTargetTypeSchema.parse(targetTypeRaw) : undefined;
const vote = voteRaw ? feedbackVoteValueSchema.parse(voteRaw) : undefined;
const status = statusRaw ? feedbackTraceStatusSchema.parse(statusRaw) : undefined;
const traces = await feedback.listFeedbackTraces({
companyId: issue.companyId,
issueId: issue.id,
targetType,
vote,
status,
from: parseDateQuery(req.query.from, "from"),
to: parseDateQuery(req.query.to, "to"),
sharedOnly: parseBooleanQuery(req.query.sharedOnly),
includePayload: parseBooleanQuery(req.query.includePayload),
});
res.json(traces);
});
router.get("/feedback-traces/:traceId", async (req, res) => {
const traceId = req.params.traceId as string;
const trace = await feedback.getFeedbackTraceById(traceId, parseBooleanQuery(req.query.includePayload) || req.query.includePayload === undefined);
if (!trace) {
res.status(404).json({ error: "Feedback trace not found" });
return;
}
assertCompanyAccess(req, trace.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can view feedback traces" });
return;
}
res.json(trace);
});
router.get("/feedback-traces/:traceId/bundle", async (req, res) => {
const traceId = req.params.traceId as string;
const bundle = await feedback.getFeedbackTraceBundle(traceId);
if (!bundle) {
res.status(404).json({ error: "Feedback trace not found" });
return;
}
assertCompanyAccess(req, bundle.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can view feedback trace bundles" });
return;
}
res.json(bundle);
});
router.post("/issues/:id/comments", validate(addIssueCommentSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@ -1539,6 +1643,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
const comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
});
if (actor.runId) {
@ -1660,6 +1765,93 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.status(201).json(comment);
});
router.post("/issues/:id/feedback-votes", validate(upsertIssueFeedbackVoteSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can vote on AI feedback" });
return;
}
const actor = getActorInfo(req);
const result = await feedback.saveIssueVote({
issueId: id,
targetType: req.body.targetType,
targetId: req.body.targetId,
vote: req.body.vote,
reason: req.body.reason,
authorUserId: req.actor.userId ?? "local-board",
allowSharing: req.body.allowSharing === true,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.feedback_vote_saved",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
targetType: result.vote.targetType,
targetId: result.vote.targetId,
vote: result.vote.vote,
hasReason: Boolean(result.vote.reason),
sharingEnabled: result.sharingEnabled,
},
});
if (result.consentEnabledNow) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.feedback_data_sharing_updated",
entityType: "company",
entityId: issue.companyId,
details: {
feedbackDataSharingEnabled: true,
source: "issue_feedback_vote",
},
});
}
if (result.persistedSharingPreference) {
const settings = await instanceSettings.get();
const companyIds = await instanceSettings.listCompanyIds();
await Promise.all(
companyIds.map((companyId) =>
logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "instance.settings.general_updated",
entityType: "instance_settings",
entityId: settings.id,
details: {
general: settings.general,
changedKeys: ["feedbackDataSharingPreference"],
source: "issue_feedback_vote",
},
}),
),
);
}
res.status(201).json(result.vote);
});
router.get("/issues/:id/attachments", async (req, res) => {
const issueId = req.params.id as string;
const issue = await svc.getById(issueId);