mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Add feedback voting and thumbs capture flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3db6bdfc3c
commit
c0d0d03bce
66 changed files with 18988 additions and 78 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue